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 +6 -5
- package/README.zh.md +6 -5
- package/lib/client.js +31 -8
- package/lib/index.js +312 -63
- package/lib/types/client/hidden.d.ts +10 -6
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/locales.d.ts +67 -0
- package/lib/types/snapshot.d.ts +107 -10
- package/package.json +6 -1
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,
|
|
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
|
|
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.
|
|
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
|
|
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`):锚点 ≥
|
|
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
|
|
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
|
-
|
|
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 #
|
|
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
|
|
37
|
-
if (
|
|
38
|
-
const match =
|
|
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
|
|
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 =
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
232
|
-
const
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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(
|
|
247
|
-
await writeFile(
|
|
248
|
-
restored.push(
|
|
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:
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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" ?
|
|
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
|
-
|
|
570
|
+
t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
|
|
384
571
|
];
|
|
385
572
|
if (files.length > 0) {
|
|
386
|
-
lines.push(
|
|
573
|
+
lines.push(t("plan.affects", { count: files.length }));
|
|
387
574
|
for (const file of files) {
|
|
388
|
-
lines.push(` ${file.action === "restore" ? "
|
|
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("
|
|
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",
|
|
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
|
|
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: "
|
|
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: "
|
|
656
|
+
return { kind: "error", text: t("stopFailed") };
|
|
441
657
|
}
|
|
442
658
|
}
|
|
443
659
|
if (invocation.signal.aborted) {
|
|
444
|
-
return { kind: "error", text: "
|
|
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:
|
|
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(
|
|
470
|
-
if (outcome.deleted.length > 0) parts.push(
|
|
471
|
-
if (outcome.skipped.length > 0) parts.push(
|
|
472
|
-
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "
|
|
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:
|
|
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": "
|
|
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: "
|
|
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:
|
|
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:
|
|
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:
|
|
743
|
+
if (parsed === void 0) return { kind: "error", text: usage() };
|
|
527
744
|
return {
|
|
528
745
|
kind: "success",
|
|
529
|
-
text:
|
|
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: "
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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.
|
|
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",
|