dsh-rewind-plugin 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -44
- package/lib/client.js +6 -6
- package/lib/index.js +198 -140
- package/lib/types/client/index.d.ts +4 -4
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +102 -0
- package/package.json +1 -1
- package/scripts/verify-host.mjs +99 -72
- package/lib/types/ledger.d.ts +0 -88
package/README.md
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
|
|
4
4
|
|
|
5
|
-
> 状态:v0.
|
|
5
|
+
> 状态:v0.2.0 已实现(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
|
|
6
6
|
|
|
7
|
-
## 实现状态(v0.
|
|
7
|
+
## 实现状态(v0.2.0)
|
|
8
8
|
|
|
9
|
-
- ✅ host 端 `/rewind`
|
|
10
|
-
- ✅
|
|
9
|
+
- ✅ host 端 `/rewind` 命令(参数化形式作为 ↶ 按钮的内部调用通道;手动输入由 client 拦截)
|
|
10
|
+
- ✅ **Claude Code 式 checkpoint 文件回退**:`tools/execute` 捕获写前备份、`tools/post-execute` 按消息分组**落盘**提交(`~/.dsh/rewind-snapshots`),重启后仍可还原
|
|
11
11
|
- ✅ **与其他审批类插件共存**:捕获在 around-dispatch 阶段,`tools/pre-execute` 被 `ask` 短路(如 dsh-edit-approval)后批准仍能记录;被拒绝的调用不留 pending 残留
|
|
12
|
-
- ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd
|
|
12
|
+
- ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd 规则),相对路径备份/还原指向真实文件;记录解析后的 display path
|
|
13
13
|
- ✅ **fs 服务动态获取**(`ctx.inject(['fs'])`):fs 后挂载也不失效,无 fs 部署时命令仍可用
|
|
14
14
|
- ✅ 同窗口 in-place 回退:追加空内容标记 + `surfaceOp: replace` 替换目标及之后全部 surface(真实 `dsh-session` 集成测试通过)
|
|
15
|
-
- ✅
|
|
16
|
-
- ✅
|
|
17
|
-
-
|
|
15
|
+
- ✅ **还原走 `node:fs` 直写**(不经 fs 服务):文件内容真正落盘;符号链接跳过并警告
|
|
16
|
+
- ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)+ 手动 `/rewind` 输入拦截
|
|
17
|
+
- ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(18 项,含审批短路/会话 cwd/新建文件删除/重启持久化场景)
|
|
18
|
+
- ⏳ 二期:快捷键、bash/外部修改的快照覆盖(整树快照)
|
|
18
19
|
|
|
19
20
|
## 安装
|
|
20
21
|
|
|
@@ -68,24 +69,24 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
68
69
|
|
|
69
70
|
- 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
|
|
70
71
|
「回退对话和代码」(后者先展示影响清单再确认)。
|
|
71
|
-
- **回退 =
|
|
72
|
+
- **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(使用消息旁的 ↶ 按钮),
|
|
72
73
|
效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
|
|
73
74
|
Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
|
|
74
75
|
可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
|
|
75
|
-
-
|
|
76
|
-
|
|
76
|
+
- **手动 `/rewind` 不支持**:在输入框手动输入 `/rewind`(含裸命令)会被 client 拦截并
|
|
77
|
+
提示改用按钮——`/rewind` 命令仅作为按钮的内部调用通道存在。
|
|
77
78
|
- **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
|
|
78
79
|
上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
|
|
79
80
|
可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
|
|
80
81
|
|
|
81
|
-
## 已知限制(v0.
|
|
82
|
+
## 已知限制(v0.2)
|
|
82
83
|
|
|
83
|
-
-
|
|
84
|
-
bash
|
|
85
|
-
|
|
86
|
-
-
|
|
84
|
+
- checkpoint 只覆盖**插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更**;
|
|
85
|
+
bash 或外部程序的修改无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
|
|
86
|
+
备份按消息分组**落盘**(每会话保留最近 100 组,最旧先清理),dsh 重启不丢失。
|
|
87
|
+
- 文件删除/还原走真实路径直删直写(本地 backend);sandbox/远程 backend 下路径解析
|
|
87
88
|
可能受限。
|
|
88
|
-
-
|
|
89
|
+
- 回退本身可再回退(标记进入日志),但文件还原动作不再记录新备份。
|
|
89
90
|
- 回退按钮只出现在**当前会话**渲染的用户消息行上(DOM 注入范围即当前视图);
|
|
90
91
|
subagent/分屏等非当前会话的对话需要先切到该会话再回退。
|
|
91
92
|
|
|
@@ -93,10 +94,10 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
93
94
|
|
|
94
95
|
社区 rewind 类插件(`dsh-recall-plugin`、`dsh-checkpoint-rewind`、`dsh-turn-rewind`)均为 **fork 路线**(回退 = fork 出新会话,用户切换会话继续),且没有「仅回退对话 / 对话+代码」的选项。本插件提供:**在当前会话窗口内**改写模型上下文 + 可选还原工作区文件。
|
|
95
96
|
|
|
96
|
-
##
|
|
97
|
+
## 交互设计(按钮两步;手动命令不支持)
|
|
97
98
|
|
|
98
99
|
按钮流程遵循两步:**第一步选择要回退到的 user 消息,第二步选择回退模式**。手动
|
|
99
|
-
`/rewind`
|
|
100
|
+
`/rewind` 命令不支持输入——client 端在输入框拦截所有手动 `/rewind` 并提示改用按钮。
|
|
100
101
|
|
|
101
102
|
### 1. 用户消息旁的「回退」按钮(主入口)
|
|
102
103
|
|
|
@@ -109,16 +110,11 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
109
110
|
- 选「回退对话和代码」时,浮层内先显示将受影响的内容清单(将还原/删除的文件名与数量),确认后执行。
|
|
110
111
|
- 执行结果以一条对话内消息呈现(如「已回退到 seq N,移除 M 条上下文;还原 2 个文件」)。
|
|
111
112
|
|
|
112
|
-
### 2.
|
|
113
|
+
### 2. 命令(仅作按钮内部通道,不支持手动输入)
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
- 手动 `/rewind` 是**单动作**:不接受任何参数,直接撤回最近一条用户消息(对话回到
|
|
119
|
-
上一条消息之前)。参数化形式(`@seq chat|both`、`preview`)仍存在于 host 端,
|
|
120
|
-
但**仅供 ↶ 按钮内部调用**——client 端会在输入框拦截带参数的手动 `/rewind` 并提示
|
|
121
|
-
改用按钮。回退到更早的消息请使用该消息旁的「回退」按钮。
|
|
115
|
+
- **手动输入 `/rewind`(含裸命令)会被 client 端在输入框直接拦截**,并提示改用消息旁
|
|
116
|
+
的 ↶ 按钮。`/rewind` 命令仅作为按钮的内部调用通道存在
|
|
117
|
+
(`/rewind @seq chat|both`、`/rewind preview @seq both`)。
|
|
122
118
|
- UI 按钮与命令共享同一套 host 端回退逻辑(`/rewind @seq <mode>`)。
|
|
123
119
|
|
|
124
120
|
## 回退机制(host 端,全部公开 API)
|
|
@@ -129,30 +125,35 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
129
125
|
- 效果:当前窗口上下文从目标点重新开始;**不产生新会话、不切换窗口**;原始日志完整保留(append-only 审计不变),仅不再进入模型上下文。
|
|
130
126
|
- 依赖:`@deepseek-ai/dsh-session`(`Session.append`、`foldSurface`)、`@deepseek-ai/dsh-llm`(`createUserMessage`)、`@deepseek-ai/dsh-commands`(命令注册)、`@deepseek-ai/dsh-agent`(`Agent.status` idle 守卫)。
|
|
131
127
|
|
|
132
|
-
### 4.
|
|
128
|
+
### 4. 文件回退:Claude Code 式 checkpoint(写前备份,按消息分组落盘)
|
|
133
129
|
|
|
134
|
-
- 在 `tools/execute`(around-dispatch
|
|
135
|
-
`
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
- 在 `tools/execute`(around-dispatch 阶段)读取目标文件**写前备份**(before;文件不存在记
|
|
131
|
+
为「新建」),`tools/post-execute` 将备份按**当前轮用户消息 seq(锚点)**落盘提交到
|
|
132
|
+
`~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/`。捕获放在 execute 而非 pre-execute:
|
|
133
|
+
**审批类插件(如 dsh-edit-approval)在 `tools/pre-execute` 返回 `ask` 会短路后续监听器**,
|
|
134
|
+
但批准后 dispatch 阶段必然执行——共存的写操作照样入备份;被拒绝的调用不 dispatch,
|
|
135
|
+
不会留下残留。
|
|
139
136
|
- 相对路径按**会话 cwd** 解析(与 `dsh-tool-fs` 同规则,`src/session-cwd.ts`),
|
|
140
|
-
|
|
141
|
-
-
|
|
142
|
-
|
|
137
|
+
备份记录解析后的 display path,preview/还原始终指向真实文件。
|
|
138
|
+
- 回退「对话和代码」到消息 N 时:对锚点 ≥ N 的每条备份取**该文件最早一条**——内容写回
|
|
139
|
+
before、新建文件删除(与 Claude Code 的 rewind 语义一致)。恢复用 `node:fs` 直写真实
|
|
140
|
+
文件,不经过 fs 服务;符号链接跳过并在结果中提示。
|
|
141
|
+
- 持久化:备份在磁盘上,**dsh 重启后仍可还原**;每会话保留最近 100 个消息分组,最旧先清理。
|
|
142
|
+
- 边界(已知限制):只覆盖**插件运行期间、经写类工具**的变更;bash 命令或外部程序的修改
|
|
143
|
+
不在备份内,无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
|
|
143
144
|
|
|
144
145
|
### 5. 安全守卫
|
|
145
146
|
|
|
146
147
|
- agent 运行中(LLM 思考/输出)执行回退时**自动强制停止**当前回合(`cancel({kind:'user'})`),
|
|
147
148
|
等待 quiescence 后回退;停止超时/失败则中止并报错。无需先手动停止。
|
|
148
149
|
- 文件还原是破坏性操作:UI 选择「对话和代码」时需经影响清单确认;命令路径用 `preview` 先行查看。
|
|
149
|
-
-
|
|
150
|
+
- 回退本身可再回退(回退动作同样进入会话日志),但文件还原动作不再记录新备份。
|
|
150
151
|
|
|
151
152
|
## 客户端实现要点(纯插件,无源码补丁)
|
|
152
153
|
|
|
153
154
|
- 按钮注入锚点:用户行 `[data-chat-flow-kind="user"]`(行容器 `data-chat-anchor-key` 为节点 key);用 MutationObserver 跟踪新增行。
|
|
154
155
|
- 消息 seq 获取:从行元素的 `data-chat-anchor-key` → 运行时快照 `session.getSnapshot().chat.nodes.get(key)` → `UserMessageNode.seq`(DOM 只用于定位,数据取自 runtime,不解析 DOM 文本)。
|
|
155
|
-
- **按钮两步选择浮层**:点击消息旁按钮时,客户端接管交互——目标即该消息,第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。**手动 `/rewind`
|
|
156
|
+
- **按钮两步选择浮层**:点击消息旁按钮时,客户端接管交互——目标即该消息,第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。**手动 `/rewind` 被 client 整体拦截**(含裸命令):输入框 guard 阻止提交并提示改用按钮;`/rewind` 命令仅作为按钮内部调用通道存在。
|
|
156
157
|
- 执行结果以命令节点出现在对话中。
|
|
157
158
|
- 注入按钮与「在新对话中分支」等官方操作并排,样式遵循 dsh 设计 token。
|
|
158
159
|
|
|
@@ -161,22 +162,22 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
161
162
|
- 快捷键(esc+esc 回退等)——独立的快捷键插件,二期。
|
|
162
163
|
- 压缩(`/compact`)——官方已有。
|
|
163
164
|
- fork/分支回退——官方已有(「在新对话中分支」)。
|
|
164
|
-
-
|
|
165
|
+
- 快照式文件回退(整树/git-first,覆盖 bash 与外部修改)——二期(checkpoint 方案先行)。
|
|
165
166
|
|
|
166
167
|
## 目录结构(实际)
|
|
167
168
|
|
|
168
169
|
```
|
|
169
|
-
src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute
|
|
170
|
+
src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute checkpoint(fs 动态注入)
|
|
170
171
|
src/rewind.ts planRewind 纯函数(目标解析、surface 范围计算、候选列表)
|
|
171
|
-
src/
|
|
172
|
+
src/snapshot.ts checkpoint 存储(写前备份按消息分组落盘、还原/删除/影响清单、有界清理)
|
|
172
173
|
src/session-cwd.ts 会话 cwd 解析(复刻 dsh-tool-fs 规则,可单测)
|
|
173
174
|
src/client/index.ts client 插件:消息行「回退」按钮 + 模式选择浮层
|
|
174
175
|
src/client/popover.ts 浮层 DOM(含 both 模式影响清单确认)
|
|
175
176
|
src/client/locales.ts zh/en 文案(LocaleNamespaceMap 合并)
|
|
176
177
|
src/client/styles.ts 注入样式(dsh 设计 token)
|
|
177
178
|
scripts/build.mjs esbuild 构建:lib/index.js(host ESM)+ lib/client.js(loader 闭包)
|
|
178
|
-
scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session
|
|
179
|
-
tests/ rewind/
|
|
179
|
+
scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session + 真实临时文件,18 项断言)
|
|
180
|
+
tests/ rewind/snapshot 单测 + 真实 dsh-session 集成测试
|
|
180
181
|
cordis.patch.yml bundle patch(插入 dsh-rewind-plugin 一行,双面)
|
|
181
182
|
package.json dsh.bundle + dsh.client 声明、optional peerDependencies
|
|
182
183
|
```
|
package/lib/client.js
CHANGED
|
@@ -45,10 +45,10 @@ var zh = {
|
|
|
45
45
|
"popover.cancel": "\u53D6\u6D88",
|
|
46
46
|
"popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
|
|
47
47
|
"popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
|
|
48
|
-
"popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\
|
|
48
|
+
"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",
|
|
49
49
|
"popover.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
50
50
|
"popover.back": "\u8FD4\u56DE",
|
|
51
|
-
"guard.hint": "/rewind \
|
|
51
|
+
"guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
|
|
52
52
|
};
|
|
53
53
|
var en = {
|
|
54
54
|
"button.aria": "Rewind to this message",
|
|
@@ -62,10 +62,10 @@ var en = {
|
|
|
62
62
|
"popover.cancel": "Cancel",
|
|
63
63
|
"popover.impact.loading": "Fetching impact list\u2026",
|
|
64
64
|
"popover.impact.failed": "Could not fetch the impact list: {message}",
|
|
65
|
-
"popover.impact.none": "No
|
|
65
|
+
"popover.impact.none": "No tracked file changes after the target; nothing to restore.",
|
|
66
66
|
"popover.confirm": "Confirm rewind",
|
|
67
67
|
"popover.back": "Back",
|
|
68
|
-
"guard.hint": "/rewind
|
|
68
|
+
"guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
// src/client/styles.ts
|
|
@@ -564,11 +564,11 @@ function apply(ctx) {
|
|
|
564
564
|
if (fillComposer(text)) filled.add(target);
|
|
565
565
|
}
|
|
566
566
|
};
|
|
567
|
-
const
|
|
567
|
+
const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
|
|
568
568
|
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
|
|
569
569
|
const hasBlockedRewindDraft = () => {
|
|
570
570
|
const textarea = composerTextarea();
|
|
571
|
-
return textarea !== null &&
|
|
571
|
+
return textarea !== null && MANUAL_REWIND.test(textarea.value);
|
|
572
572
|
};
|
|
573
573
|
let guardHintEl = null;
|
|
574
574
|
let guardHintTimer;
|
package/lib/index.js
CHANGED
|
@@ -2,101 +2,6 @@
|
|
|
2
2
|
import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
|
|
5
|
-
// src/session-cwd.ts
|
|
6
|
-
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
7
|
-
var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
8
|
-
function sessionCwd(cwd, requestedPath) {
|
|
9
|
-
if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
|
|
10
|
-
return canonicalPath(cwd);
|
|
11
|
-
}
|
|
12
|
-
function execSessionCwd(exec, requestedPath) {
|
|
13
|
-
return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// src/ledger.ts
|
|
17
|
-
var MAX_LEDGER_ENTRIES = 2e3;
|
|
18
|
-
var RewindLedger = class {
|
|
19
|
-
entries = [];
|
|
20
|
-
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
21
|
-
record(entry) {
|
|
22
|
-
this.entries.push(entry);
|
|
23
|
-
if (this.entries.length > MAX_LEDGER_ENTRIES) this.entries.shift();
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
27
|
-
* is inclusive: rewinding to a message also reverts the changes its own
|
|
28
|
-
* turn caused (the rewind cut removes that turn's assistant response and
|
|
29
|
-
* tool calls), so only changes anchored at earlier messages survive.
|
|
30
|
-
*/
|
|
31
|
-
changesAfter(targetSeq) {
|
|
32
|
-
const after = [];
|
|
33
|
-
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
34
|
-
const entry = this.entries[i];
|
|
35
|
-
if (entry.anchorSeq >= targetSeq) after.push(entry);
|
|
36
|
-
}
|
|
37
|
-
return after;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Unique per-file impact for preview. A file whose earliest affected change
|
|
41
|
-
* created it (`before === undefined`) is deleted on restore; any other file
|
|
42
|
-
* is written back to its pre-target content.
|
|
43
|
-
*/
|
|
44
|
-
impactsAfter(targetSeq) {
|
|
45
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
46
|
-
for (const entry of this.entries) {
|
|
47
|
-
if (entry.anchorSeq < targetSeq) continue;
|
|
48
|
-
if (byPath.has(entry.path)) continue;
|
|
49
|
-
byPath.set(entry.path, { path: entry.path, action: entry.before === void 0 ? "delete" : "restore" });
|
|
50
|
-
}
|
|
51
|
-
return [...byPath.values()];
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
55
|
-
* its pre-change content back; a file that did not exist before the target
|
|
56
|
-
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
57
|
-
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
58
|
-
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
59
|
-
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
60
|
-
* @param options - session workspace cwd (relative ledger paths resolve
|
|
61
|
-
* against it, mirroring the fs tools) and an optional abort signal.
|
|
62
|
-
*/
|
|
63
|
-
async restoreAfter(fs, deleteFile, targetSeq, options = {}) {
|
|
64
|
-
const restored = [];
|
|
65
|
-
const deleted = [];
|
|
66
|
-
const failed = [];
|
|
67
|
-
const restoredSet = /* @__PURE__ */ new Set();
|
|
68
|
-
const deletedSet = /* @__PURE__ */ new Set();
|
|
69
|
-
for (const entry of this.changesAfter(targetSeq)) {
|
|
70
|
-
try {
|
|
71
|
-
const cwd = sessionCwd(options.cwd, entry.path);
|
|
72
|
-
const target = await fs.resolve(entry.path, {
|
|
73
|
-
...cwd !== void 0 ? { cwd } : {},
|
|
74
|
-
signal: options.signal
|
|
75
|
-
});
|
|
76
|
-
if (entry.before === void 0) {
|
|
77
|
-
await deleteFile(fs.processPath(target));
|
|
78
|
-
if (!deletedSet.has(entry.path)) {
|
|
79
|
-
deletedSet.add(entry.path);
|
|
80
|
-
deleted.push(entry.path);
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
await fs.writeText(target, entry.before, void 0, options.signal);
|
|
84
|
-
if (!restoredSet.has(entry.path)) {
|
|
85
|
-
restoredSet.add(entry.path);
|
|
86
|
-
restored.push(entry.path);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} catch (error) {
|
|
90
|
-
failed.push({
|
|
91
|
-
path: entry.path,
|
|
92
|
-
message: error instanceof Error ? error.message : String(error)
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return { restored, deleted, failed };
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
5
|
// src/rewind.ts
|
|
101
6
|
var RewindError = class extends Error {
|
|
102
7
|
constructor(code, message) {
|
|
@@ -179,6 +84,180 @@ function planRewind(events, surface, target) {
|
|
|
179
84
|
};
|
|
180
85
|
}
|
|
181
86
|
|
|
87
|
+
// src/session-cwd.ts
|
|
88
|
+
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
89
|
+
var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
90
|
+
function sessionCwd(cwd, requestedPath) {
|
|
91
|
+
if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
|
|
92
|
+
return canonicalPath(cwd);
|
|
93
|
+
}
|
|
94
|
+
function execSessionCwd(exec, requestedPath) {
|
|
95
|
+
return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/snapshot.ts
|
|
99
|
+
import { mkdir, readFile, readdir, rm, writeFile, lstat } from "node:fs/promises";
|
|
100
|
+
import { join } from "node:path";
|
|
101
|
+
import { homedir } from "node:os";
|
|
102
|
+
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
103
|
+
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
104
|
+
var MAX_ANCHOR_GROUPS = 100;
|
|
105
|
+
function safeFileId(callId) {
|
|
106
|
+
return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
107
|
+
}
|
|
108
|
+
async function readEntry(file) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
111
|
+
if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
|
|
112
|
+
return {
|
|
113
|
+
callId: String(parsed.callId ?? ""),
|
|
114
|
+
anchorSeq: parsed.anchorSeq,
|
|
115
|
+
path: parsed.path,
|
|
116
|
+
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
117
|
+
time: typeof parsed.time === "number" ? parsed.time : 0
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return void 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function isSymbolicLink(path) {
|
|
124
|
+
try {
|
|
125
|
+
return (await lstat(path)).isSymbolicLink();
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
var SnapshotStore = class {
|
|
131
|
+
constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
|
|
132
|
+
this.root = root;
|
|
133
|
+
}
|
|
134
|
+
root;
|
|
135
|
+
/** Absolute path of one anchor group directory. */
|
|
136
|
+
anchorDir(sessionId, anchorSeq) {
|
|
137
|
+
return join(this.root, sessionId, String(anchorSeq));
|
|
138
|
+
}
|
|
139
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
140
|
+
async recordEntry(sessionId, entry) {
|
|
141
|
+
const dir = this.anchorDir(sessionId, entry.anchorSeq);
|
|
142
|
+
await mkdir(dir, { recursive: true });
|
|
143
|
+
const committed = { ...entry, time: Date.now() };
|
|
144
|
+
await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
|
|
145
|
+
await this.prune(sessionId);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
149
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
150
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
151
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
152
|
+
* earlier messages survive.
|
|
153
|
+
*/
|
|
154
|
+
async entriesAfter(sessionId, targetSeq) {
|
|
155
|
+
const sessionDir = join(this.root, sessionId);
|
|
156
|
+
let names;
|
|
157
|
+
try {
|
|
158
|
+
names = await readdir(sessionDir);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code === "ENOENT") return [];
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const name2 of names) {
|
|
165
|
+
const anchorSeq = Number(name2);
|
|
166
|
+
if (!Number.isSafeInteger(anchorSeq) || anchorSeq < targetSeq) continue;
|
|
167
|
+
const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
if (!file.endsWith(".json")) continue;
|
|
170
|
+
const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
|
|
171
|
+
if (entry !== void 0) entries.push(entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
|
|
175
|
+
}
|
|
176
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
177
|
+
async impactsAfter(sessionId, targetSeq) {
|
|
178
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
179
|
+
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
180
|
+
const current = earliest.get(entry.path);
|
|
181
|
+
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
182
|
+
earliest.set(entry.path, entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [...earliest.values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
|
|
186
|
+
path: entry.path,
|
|
187
|
+
action: entry.before === null ? "delete" : "restore"
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
192
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
193
|
+
* before content back, or delete the file when it was created after the
|
|
194
|
+
* target. Symbolic links are skipped (reported, never written through).
|
|
195
|
+
* Failures are per-file and never abort the pass.
|
|
196
|
+
*/
|
|
197
|
+
async restoreAfter(sessionId, targetSeq, deleteFile) {
|
|
198
|
+
const restored = [];
|
|
199
|
+
const deleted = [];
|
|
200
|
+
const skipped = [];
|
|
201
|
+
const failed = [];
|
|
202
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
|
|
204
|
+
const current = earliest.get(entry.path);
|
|
205
|
+
if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
|
|
206
|
+
earliest.set(entry.path, entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const entry of earliest.values()) {
|
|
210
|
+
try {
|
|
211
|
+
if (await isSymbolicLink(entry.path)) {
|
|
212
|
+
skipped.push(entry.path);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (entry.before === null) {
|
|
216
|
+
await deleteFile(entry.path);
|
|
217
|
+
deleted.push(entry.path);
|
|
218
|
+
} else {
|
|
219
|
+
await writeFile(entry.path, entry.before, "utf8");
|
|
220
|
+
restored.push(entry.path);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { restored, deleted, skipped, failed };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
230
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
231
|
+
*/
|
|
232
|
+
async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
|
|
233
|
+
const sessionDir = join(this.root, sessionId);
|
|
234
|
+
let names;
|
|
235
|
+
try {
|
|
236
|
+
names = await readdir(sessionDir);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.code === "ENOENT") return;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
|
|
242
|
+
const excess = seqs.length - keep;
|
|
243
|
+
if (excess <= 0) return;
|
|
244
|
+
for (const seq of seqs.slice(0, excess)) {
|
|
245
|
+
await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
249
|
+
async exists(path) {
|
|
250
|
+
const { stat } = await import("node:fs/promises");
|
|
251
|
+
try {
|
|
252
|
+
await stat(path);
|
|
253
|
+
return true;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error.code === "ENOENT") return false;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
182
261
|
// src/index.ts
|
|
183
262
|
var name = "dsh-rewind";
|
|
184
263
|
var inject = ["commands", "tools"];
|
|
@@ -234,9 +313,9 @@ async function captureBefore(fs, exec, pending) {
|
|
|
234
313
|
const target = await resolveTarget(fs, path, cwd, exec.signal);
|
|
235
314
|
if (target === void 0) return;
|
|
236
315
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
237
|
-
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath,
|
|
316
|
+
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
238
317
|
}
|
|
239
|
-
async function commitEntry(
|
|
318
|
+
async function commitEntry(store, pending, exec, result) {
|
|
240
319
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
241
320
|
const capture = pending.get(key);
|
|
242
321
|
if (capture === void 0) return;
|
|
@@ -246,20 +325,11 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
|
|
|
246
325
|
if (agent === void 0) return;
|
|
247
326
|
const anchorSeq = anchorSeqOf(agent.session);
|
|
248
327
|
if (anchorSeq === void 0) return;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let after;
|
|
252
|
-
try {
|
|
253
|
-
after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
|
|
254
|
-
} catch {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
ledgerFor(agent.session).record({
|
|
258
|
-
toolName: exec.name,
|
|
328
|
+
await store.recordEntry(agent.session.id, {
|
|
329
|
+
callId: exec.callId,
|
|
259
330
|
anchorSeq,
|
|
260
331
|
path: capture.path,
|
|
261
|
-
before: capture.before
|
|
262
|
-
after
|
|
332
|
+
before: capture.before ?? null
|
|
263
333
|
});
|
|
264
334
|
}
|
|
265
335
|
function buildMarker() {
|
|
@@ -290,7 +360,7 @@ function formatPlan(plan, files) {
|
|
|
290
360
|
lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
|
|
291
361
|
}
|
|
292
362
|
} else {
|
|
293
|
-
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\
|
|
363
|
+
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");
|
|
294
364
|
}
|
|
295
365
|
return lines.join("\n");
|
|
296
366
|
}
|
|
@@ -313,7 +383,7 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
313
383
|
}
|
|
314
384
|
return true;
|
|
315
385
|
}
|
|
316
|
-
async function executeRewind(ctx,
|
|
386
|
+
async function executeRewind(ctx, store, invocation, rawTarget, mode) {
|
|
317
387
|
const { agent } = invocation;
|
|
318
388
|
if (agent.status !== "idle") {
|
|
319
389
|
agent.cancel({ kind: "user" });
|
|
@@ -343,18 +413,13 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
343
413
|
}
|
|
344
414
|
let restore = "";
|
|
345
415
|
if (mode === "both") {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
cwd: agent.session.header.cwd,
|
|
354
|
-
signal: invocation.signal
|
|
355
|
-
});
|
|
356
|
-
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
357
|
-
}
|
|
416
|
+
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
417
|
+
const parts = [];
|
|
418
|
+
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
419
|
+
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
420
|
+
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u7B26\u53F7\u94FE\u63A5`);
|
|
421
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
422
|
+
restore += renderFailures(outcome.failed);
|
|
358
423
|
}
|
|
359
424
|
return {
|
|
360
425
|
kind: "success",
|
|
@@ -374,7 +439,7 @@ function rewindErrorResult(error) {
|
|
|
374
439
|
}
|
|
375
440
|
throw error;
|
|
376
441
|
}
|
|
377
|
-
async function handleRewind(ctx,
|
|
442
|
+
async function handleRewind(ctx, store, invocation) {
|
|
378
443
|
const session = invocation.agent.session;
|
|
379
444
|
const input = invocation.rawInput.trim();
|
|
380
445
|
if (input === "") {
|
|
@@ -382,7 +447,7 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
382
447
|
if (candidates.length === 0) {
|
|
383
448
|
return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
|
|
384
449
|
}
|
|
385
|
-
return executeRewind(ctx,
|
|
450
|
+
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
|
|
386
451
|
}
|
|
387
452
|
const parts = input.split(/\s+/);
|
|
388
453
|
if (parts[0] === "preview") {
|
|
@@ -394,7 +459,7 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
394
459
|
} catch (error) {
|
|
395
460
|
return rewindErrorResult(error);
|
|
396
461
|
}
|
|
397
|
-
const impacts =
|
|
462
|
+
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
398
463
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
399
464
|
}
|
|
400
465
|
const target = parts[0];
|
|
@@ -412,24 +477,16 @@ async function handleRewind(ctx, ledger, invocation) {
|
|
|
412
477
|
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
413
478
|
};
|
|
414
479
|
}
|
|
415
|
-
return executeRewind(ctx,
|
|
480
|
+
return executeRewind(ctx, store, invocation, target, mode);
|
|
416
481
|
}
|
|
417
|
-
function apply(ctx) {
|
|
418
|
-
const
|
|
419
|
-
const ledgerFor = (session) => {
|
|
420
|
-
let ledger = ledgers.get(session.id);
|
|
421
|
-
if (ledger === void 0) {
|
|
422
|
-
ledger = new RewindLedger();
|
|
423
|
-
ledgers.set(session.id, ledger);
|
|
424
|
-
}
|
|
425
|
-
return ledger;
|
|
426
|
-
};
|
|
482
|
+
function apply(ctx, config) {
|
|
483
|
+
const store = new SnapshotStore(config?.snapshotDir);
|
|
427
484
|
const pending = /* @__PURE__ */ new Map();
|
|
428
485
|
ctx.effect(function* () {
|
|
429
486
|
yield ctx.commands.register({
|
|
430
487
|
name: "rewind",
|
|
431
488
|
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",
|
|
432
|
-
handler: (invocation) => handleRewind(ctx,
|
|
489
|
+
handler: (invocation) => handleRewind(ctx, store, invocation)
|
|
433
490
|
});
|
|
434
491
|
}, "dsh-rewind command");
|
|
435
492
|
ctx.inject(["fs"], (scope) => {
|
|
@@ -444,15 +501,16 @@ function apply(ctx) {
|
|
|
444
501
|
});
|
|
445
502
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
446
503
|
try {
|
|
447
|
-
await commitEntry(
|
|
504
|
+
await commitEntry(store, pending, exec, result);
|
|
448
505
|
} catch (error) {
|
|
449
|
-
ctx.logger.warn(`[dsh-rewind]
|
|
506
|
+
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
450
507
|
}
|
|
451
508
|
return next();
|
|
452
509
|
});
|
|
453
510
|
});
|
|
454
511
|
}
|
|
455
512
|
export {
|
|
513
|
+
SnapshotStore,
|
|
456
514
|
apply,
|
|
457
515
|
inject,
|
|
458
516
|
name
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
* before executing. Execution always goes through `session.command(...)`, the
|
|
17
17
|
* same host path the `/rewind` command uses.
|
|
18
18
|
*
|
|
19
|
-
* Manual composer input of `/rewind` is deliberately
|
|
20
|
-
* below):
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
19
|
+
* Manual composer input of `/rewind` is deliberately blocked (the guard
|
|
20
|
+
* below): the command exists only as the per-message button's internal
|
|
21
|
+
* channel, so any `/rewind` line typed by hand — bare or with arguments — is
|
|
22
|
+
* stopped with a hint pointing at the button.
|
|
23
23
|
*
|
|
24
24
|
* @module dsh-rewind/client
|
|
25
25
|
*/
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,35 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-rewind host half: the `/rewind` command and the
|
|
3
|
-
*
|
|
4
|
-
* `src/client/`).
|
|
2
|
+
* dsh-rewind host half: the `/rewind` command and the Claude-Code-style
|
|
3
|
+
* checkpoint store, composed as one dual-face bundle row (the browser half
|
|
4
|
+
* lives in `src/client/`).
|
|
5
5
|
*
|
|
6
6
|
* Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
|
|
7
|
-
* marker `
|
|
8
|
-
* surface node after the target message with the marker. The
|
|
9
|
-
* (and the rendered transcript) is untouched — only the
|
|
10
|
-
* is cut, so the next request derives its context from
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* marker `assistant/message` into the session log whose `surfaceOp` replaces
|
|
8
|
+
* every surface node after the target message with the marker. The
|
|
9
|
+
* append-only log (and the rendered transcript) is untouched — only the
|
|
10
|
+
* model-visible surface is cut, so the next request derives its context from
|
|
11
|
+
* the target onward.
|
|
12
|
+
*
|
|
13
|
+
* File restore (mode `both`) follows Claude Code's checkpointing: the plugin
|
|
14
|
+
* backs up each tracked write-class edit BEFORE it happens (at the
|
|
15
|
+
* `tools/execute` around-dispatch stage, so an approval short-circuit cannot
|
|
16
|
+
* skip the capture and a denied call never records), commits the backup under
|
|
17
|
+
* the turn's anchor message seq at `tools/post-execute`, and a rewind to
|
|
18
|
+
* message N restores every backup anchored at or after N — modified files are
|
|
19
|
+
* written back to their pre-edit content, files created after N are deleted.
|
|
20
|
+
* Backups persist on disk under the dsh data directory (newest 100 message
|
|
21
|
+
* groups per session), so restores work after a host restart, and they
|
|
22
|
+
* read/write the real file system with plain `node:fs` — independent of the
|
|
23
|
+
* fs service. See `src/snapshot.ts`.
|
|
13
24
|
*
|
|
14
25
|
* @module dsh-rewind
|
|
15
26
|
*/
|
|
16
27
|
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
export { SnapshotStore } from './snapshot.ts';
|
|
29
|
+
export type { CheckpointEntry, FileImpact, RestoreOutcome } from './snapshot.ts';
|
|
17
30
|
export declare const name = "dsh-rewind";
|
|
18
31
|
export declare const inject: string[];
|
|
32
|
+
/** Plugin config: optional override of the checkpoint store root. */
|
|
33
|
+
export interface RewindConfig {
|
|
34
|
+
/** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
|
|
35
|
+
readonly snapshotDir?: string;
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
|
-
* Register the `/rewind` command and the
|
|
38
|
+
* Register the `/rewind` command and the checkpoint pipeline (before-capture
|
|
39
|
+
* at `tools/execute`, disk commit at `tools/post-execute`).
|
|
21
40
|
*
|
|
22
|
-
* The command is fs-independent and registers immediately. The
|
|
23
|
-
* `fs
|
|
24
|
-
*
|
|
25
|
-
*
|
|
41
|
+
* The command is fs-independent and registers immediately. The checkpoint
|
|
42
|
+
* pipeline needs `fs` to resolve tracked paths to their real display paths,
|
|
43
|
+
* so it mounts through a dynamic `ctx.inject(['fs'])` — it takes effect
|
|
44
|
+
* whenever the fs service becomes available (and never fails the plugin's
|
|
45
|
+
* load when a deployment has no fs; without it, no entries are recorded and
|
|
46
|
+
* `both` restores report "no tracked changes").
|
|
26
47
|
*
|
|
27
48
|
* Capture runs in `tools/execute` (the around-dispatch stage), NOT in
|
|
28
49
|
* `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
|
|
29
50
|
* another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
|
|
30
51
|
* and a denied call never dispatches — so approved calls are still captured,
|
|
31
|
-
* denied calls never leave a pending entry behind.
|
|
52
|
+
* denied calls never leave a pending entry behind. Entries are committed to
|
|
53
|
+
* disk at `tools/post-execute` under the turn's anchor message seq.
|
|
32
54
|
*
|
|
33
55
|
* @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
|
|
56
|
+
* @param config - optional override of the checkpoint store root.
|
|
34
57
|
*/
|
|
35
|
-
export declare function apply(ctx: Context): void;
|
|
58
|
+
export declare function apply(ctx: Context, config?: RewindConfig): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session-cwd resolution for
|
|
2
|
+
* Session-cwd resolution for snapshot tracking reads, mirroring the fs tools'
|
|
3
3
|
* own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
|
|
4
4
|
* resolve against the calling agent's session workspace
|
|
5
5
|
* (`exec.agent.session.header.cwd`), not the server's launch dir.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint store — the Claude Code style file-rewind backing for dsh-rewind.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's checkpointing (see README) works like this: it creates a
|
|
5
|
+
* BACKUP of a file BEFORE every tracked modification, groups those backups by
|
|
6
|
+
* the user message they belong to (a "checkpoint"), and rewinding to a
|
|
7
|
+
* checkpoint restores every backup recorded at or after it — modified files
|
|
8
|
+
* are written back to their pre-edit content, files created after the target
|
|
9
|
+
* are deleted. This module is the same design, persisted on disk:
|
|
10
|
+
*
|
|
11
|
+
* - `tools/execute` captures the BEFORE state of each tracked write/edit call
|
|
12
|
+
* (or "created" when the file did not exist) — the capture happens at the
|
|
13
|
+
* around-dispatch stage, so an approval `ask` short-circuit cannot skip it
|
|
14
|
+
* and a denied call never records.
|
|
15
|
+
* - The entry is committed to disk at `tools/post-execute` under the turn's
|
|
16
|
+
* anchor seq: `<root>/<sessionId>/<anchorSeq>/<callId>.json`, carrying the
|
|
17
|
+
* path and the before content (`before: null` = the file was created).
|
|
18
|
+
* - Because entries live on disk under the dsh data directory, they survive a
|
|
19
|
+
* host restart, are bounded (the newest 100 anchor groups per session are
|
|
20
|
+
* kept), and restores read/write the real file system with plain `node:fs`
|
|
21
|
+
* — independent of the fs service.
|
|
22
|
+
*
|
|
23
|
+
* Restore semantics (identical to Claude Code): for every path with entries
|
|
24
|
+
* anchored at or after the target message, apply the EARLIEST entry — write
|
|
25
|
+
* the before content back, or delete the file when that entry recorded a
|
|
26
|
+
* creation. Symbolic links are skipped and reported, never written through.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-rewind/snapshot
|
|
29
|
+
*/
|
|
30
|
+
/** Default store root: the dsh data directory. */
|
|
31
|
+
export declare const DEFAULT_SNAPSHOT_ROOT: string;
|
|
32
|
+
/** Environment variable overriding the store root (tests, exotic homes). */
|
|
33
|
+
export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
34
|
+
/** Number of newest anchor groups (user messages) kept per session. */
|
|
35
|
+
export declare const MAX_ANCHOR_GROUPS = 100;
|
|
36
|
+
/** One committed before-backup, keyed by tool call. */
|
|
37
|
+
export interface CheckpointEntry {
|
|
38
|
+
readonly callId: string;
|
|
39
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
40
|
+
readonly anchorSeq: number;
|
|
41
|
+
/** Resolved display path (absolute) of the tracked file. */
|
|
42
|
+
readonly path: string;
|
|
43
|
+
/** Full content before the change; null when the file was created. */
|
|
44
|
+
readonly before: string | null;
|
|
45
|
+
/** Epoch ms the entry was committed (stable ordering within a group). */
|
|
46
|
+
readonly time: number;
|
|
47
|
+
}
|
|
48
|
+
/** Per-file restore impact preview (`/rewind preview @seq both`). */
|
|
49
|
+
export interface FileImpact {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
52
|
+
readonly action: 'restore' | 'delete';
|
|
53
|
+
}
|
|
54
|
+
/** Outcome of one restore pass. */
|
|
55
|
+
export interface RestoreOutcome {
|
|
56
|
+
readonly restored: readonly string[];
|
|
57
|
+
readonly deleted: readonly string[];
|
|
58
|
+
readonly skipped: readonly string[];
|
|
59
|
+
readonly failed: readonly {
|
|
60
|
+
path: string;
|
|
61
|
+
message: string;
|
|
62
|
+
}[];
|
|
63
|
+
}
|
|
64
|
+
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
65
|
+
export type DeleteFile = (path: string) => Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* On-disk checkpoint store. Every write goes straight through `node:fs`, so a
|
|
68
|
+
* restore reliably lands on the real file system.
|
|
69
|
+
*/
|
|
70
|
+
export declare class SnapshotStore {
|
|
71
|
+
readonly root: string;
|
|
72
|
+
constructor(root?: string);
|
|
73
|
+
/** Absolute path of one anchor group directory. */
|
|
74
|
+
anchorDir(sessionId: string, anchorSeq: number): string;
|
|
75
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
76
|
+
recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
79
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
80
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
81
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
82
|
+
* earlier messages survive.
|
|
83
|
+
*/
|
|
84
|
+
entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
|
|
85
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
86
|
+
impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
89
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
90
|
+
* before content back, or delete the file when it was created after the
|
|
91
|
+
* target. Symbolic links are skipped (reported, never written through).
|
|
92
|
+
* Failures are per-file and never abort the pass.
|
|
93
|
+
*/
|
|
94
|
+
restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
|
|
95
|
+
/**
|
|
96
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
97
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
98
|
+
*/
|
|
99
|
+
prune(sessionId: string, keep?: number): Promise<void>;
|
|
100
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
101
|
+
exists(path: string): Promise<boolean>;
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
package/scripts/verify-host.mjs
CHANGED
|
@@ -2,49 +2,58 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Host-half verification: boots the built plugin (`lib/index.js`) on a real
|
|
4
4
|
* cordis context with a real dsh-session, then drives the `/rewind` command
|
|
5
|
-
* handler and the
|
|
5
|
+
* handler and the checkpoint pipeline end to end — no model, no UI. Files are
|
|
6
|
+
* real files under a temporary directory, so a restore is verified against
|
|
7
|
+
* actual on-disk content, and the checkpoint store root is overridden to that
|
|
8
|
+
* temporary directory.
|
|
6
9
|
*
|
|
7
10
|
* Run: `npm run build && node scripts/verify-host.mjs`
|
|
8
11
|
*
|
|
9
12
|
* What it proves:
|
|
10
13
|
* 1. the plugin registers a `rewind` command on the ctx;
|
|
11
14
|
* 2. `/rewind` (no args) withdraws the most recent user message;
|
|
12
|
-
* 3. `/rewind @<seq> chat`
|
|
13
|
-
* 4.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
|
|
16
|
+
* 4. a successful write through the tools pipeline commits a before-backup
|
|
17
|
+
* under the turn's anchor seq;
|
|
18
|
+
* 5. a denied call never commits (no phantom entry in the store);
|
|
19
|
+
* 6. relative file paths resolve against the session cwd (fs-tools rule);
|
|
20
|
+
* 7. `/rewind preview @<seq> both` reports the checkpoint impact;
|
|
21
|
+
* 8. `/rewind @<seq> both` restores the real file to its pre-edit content
|
|
22
|
+
* and deletes files created after the target;
|
|
23
|
+
* 9. a running agent is force-stopped before the rewind (not refused);
|
|
24
|
+
* 10. a cancel that never quiesces aborts the rewind (timeout path).
|
|
19
25
|
*/
|
|
20
26
|
import { Context } from '@deepseek-ai/cordis'
|
|
21
27
|
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
|
22
28
|
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
29
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
30
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
31
|
+
import { tmpdir } from 'node:os'
|
|
24
32
|
import { join } from 'node:path'
|
|
25
33
|
import { apply as applyRewind } from '../lib/index.js'
|
|
26
34
|
|
|
27
35
|
const aborted = () => new AbortController().signal
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
const tmpRoot = await mkdtemp(join(tmpdir(), 'dsh-rewind-verify-'))
|
|
38
|
+
const wsDir = join(tmpRoot, 'ws')
|
|
39
|
+
const snapRoot = join(tmpRoot, 'snapshots')
|
|
40
|
+
await mkdir(wsDir, { recursive: true })
|
|
41
|
+
|
|
42
|
+
/** Real-filesystem fs double: resolve returns the real display path. */
|
|
30
43
|
class FakeFs extends FileSystem {
|
|
31
|
-
files = new Map()
|
|
32
44
|
async resolve(path, opts = {}) {
|
|
33
45
|
const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
|
|
34
46
|
return { targetKey: FsTargetKey(displayPath), displayPath }
|
|
35
47
|
}
|
|
36
48
|
processPath(target) { return target.displayPath }
|
|
37
|
-
async readText(target) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
49
|
+
async readText(target) { return readFile(target.displayPath, 'utf8') }
|
|
50
|
+
async writeText(target, content) { await writeFile(target.displayPath, content, 'utf8'); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
|
|
51
|
+
async stat(target) {
|
|
52
|
+
try { await readFile(target.displayPath); return { version: FsVersion('v'), type: 'file' } } catch { return undefined }
|
|
41
53
|
}
|
|
42
|
-
async writeText(target, content) { this.files.set(target.displayPath, content); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
|
|
43
|
-
async stat(target) { return this.files.has(target.displayPath) ? { version: FsVersion('v'), type: 'file' } : undefined }
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const fs = new FakeFs(new Context())
|
|
47
|
-
fs.files.set('/workspace/a.txt', 'original content')
|
|
48
57
|
|
|
49
58
|
const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
50
59
|
const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
|
|
@@ -73,10 +82,20 @@ ctx.provide('commands', {
|
|
|
73
82
|
},
|
|
74
83
|
})
|
|
75
84
|
ctx.provide('fs', fs)
|
|
76
|
-
applyRewind(ctx)
|
|
85
|
+
applyRewind(ctx, { snapshotDir: snapRoot })
|
|
77
86
|
|
|
78
87
|
const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
|
|
79
88
|
|
|
89
|
+
/** Simulate one tracked tool call: before-capture, dispatch writes the file, post-execute commits. */
|
|
90
|
+
async function runWrite(agentOf, callId, filePath, content) {
|
|
91
|
+
const exec = { callId, name: 'write', arguments: { file_path: filePath, content }, agent: agentOf, signal: aborted() }
|
|
92
|
+
await ctx.waterfall('tools/execute', exec, async () => {
|
|
93
|
+
await fs.writeText({ targetKey: FsTargetKey(filePath), displayPath: filePath }, content)
|
|
94
|
+
return { isError: false, content: [] }
|
|
95
|
+
})
|
|
96
|
+
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
let failures = 0
|
|
81
100
|
const check = (name, ok, detail) => {
|
|
82
101
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
|
|
@@ -106,74 +125,82 @@ check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
|
|
|
106
125
|
check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
|
|
107
126
|
check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
|
|
108
127
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// 4. a pre-execute `ask` short-circuit (dsh-edit-approval) must not skip the
|
|
114
|
-
// capture: capture happens in tools/execute, which runs after approval.
|
|
128
|
+
// 4. a tracked write commits a before-backup; rewinding both restores the
|
|
129
|
+
// real file and deletes files created after the target
|
|
115
130
|
{
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
await
|
|
124
|
-
|
|
131
|
+
const aPath = join(wsDir, 'a.txt')
|
|
132
|
+
await writeFile(aPath, 'original content', 'utf8')
|
|
133
|
+
// The next user message anchors the turn that will edit a.txt (seq 5).
|
|
134
|
+
session.append('user/message', user('third question'), { surfaceOp: 'append' })
|
|
135
|
+
const anchorSeq = 5
|
|
136
|
+
await runWrite(agent, 'c1', aPath, 'rewritten') // before-capture: 'original content'
|
|
137
|
+
const createdPath = join(wsDir, 'created.txt')
|
|
138
|
+
await runWrite(agent, 'c2', createdPath, 'new') // file did not exist: before-capture = created
|
|
139
|
+
await writeFile(aPath, 'v3', 'utf8') // later edit lands after the backups
|
|
140
|
+
|
|
141
|
+
const preview = await call(agent, `preview @${anchorSeq} both`)
|
|
142
|
+
check('preview reports the file impact', preview.kind === 'success' && preview.text.includes(aPath) && preview.text.includes('还原'), preview.text)
|
|
143
|
+
|
|
144
|
+
const both = await call(agent, `@${anchorSeq} both`)
|
|
145
|
+
check('rewind both succeeds', both.kind === 'success' && both.text.includes('还原 1 个文件') && both.text.includes('删除 1 个文件'), both.text)
|
|
146
|
+
check('modified file restored to pre-edit content', await readFile(aPath, 'utf8') === 'original content', await readFile(aPath, 'utf8'))
|
|
147
|
+
let createdGone = false
|
|
148
|
+
try { await readFile(createdPath, 'utf8') } catch { createdGone = true }
|
|
149
|
+
check('created file deleted', createdGone, `exists=${!createdGone}`)
|
|
125
150
|
}
|
|
126
151
|
|
|
127
|
-
// 5. a denied call never
|
|
152
|
+
// 5. a denied call never commits (no phantom entry)
|
|
128
153
|
{
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
const deniedPath = join(wsDir, 'denied.txt')
|
|
155
|
+
await writeFile(deniedPath, 'x', 'utf8')
|
|
156
|
+
const exec = { callId: 'c3', name: 'write', arguments: { file_path: deniedPath, content: 'denied write' }, agent, signal: aborted() }
|
|
157
|
+
await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
|
|
133
158
|
await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
|
|
159
|
+
const preview = await call(agent, 'preview @5 both')
|
|
160
|
+
check('denied call is not in the impact list', !preview.text.includes(deniedPath), preview.text)
|
|
134
161
|
}
|
|
135
162
|
|
|
136
163
|
// 6. relative paths resolve against the session cwd (fs-tools rule)
|
|
137
164
|
{
|
|
138
|
-
const cwdSession = buildSession('verify-cwd',
|
|
165
|
+
const cwdSession = buildSession('verify-cwd', wsDir)
|
|
139
166
|
const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
check('both restores cwd-resolved file', both.kind === 'success' && fs.files.get('/workspace/rel.txt') === 'relative original', both.text)
|
|
167
|
+
const relPath = join(wsDir, 'rel.txt')
|
|
168
|
+
await writeFile(relPath, 'relative original', 'utf8')
|
|
169
|
+
cwdSession.append('user/message', user('relative question'), { surfaceOp: 'append' })
|
|
170
|
+
await runWrite(cwdAgent, 'c4', 'rel.txt', 'relative new') // relative path
|
|
171
|
+
|
|
172
|
+
const preview = await call(cwdAgent, 'preview @4 both')
|
|
173
|
+
check('relative path resolved via session cwd', preview.kind === 'success' && preview.text.includes(relPath), preview.text)
|
|
174
|
+
const both = await call(cwdAgent, '@4 both')
|
|
175
|
+
check('both restores cwd-resolved file', both.kind === 'success' && await readFile(relPath, 'utf8') === 'relative original', both.text)
|
|
150
176
|
}
|
|
151
177
|
|
|
152
|
-
// 7. preview
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const bothResult = await call(agent, '@0 both')
|
|
159
|
-
check('rewind both restores file', bothResult.kind === 'success' && bothResult.text.includes('还原 1 个文件'), bothResult.text)
|
|
160
|
-
check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
|
|
161
|
-
|
|
162
|
-
// 9. a running agent is force-stopped before the rewind (not refused)
|
|
163
|
-
const runningSession = buildSession('verify-running')
|
|
164
|
-
const running = {
|
|
165
|
-
...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running',
|
|
166
|
-
cancel: () => { cancelled = true; running.status = 'idle' },
|
|
178
|
+
// 7. preview on a message with no recorded changes reports none
|
|
179
|
+
{
|
|
180
|
+
const cleanSession = buildSession('verify-norec')
|
|
181
|
+
const cleanAgent = { id: cleanSession.id, session: cleanSession, status: 'idle' }
|
|
182
|
+
const previewResult = await call(cleanAgent, 'preview @2 both')
|
|
183
|
+
check('preview with no entries reports no changes', previewResult.kind === 'success' && previewResult.text.includes('无需还原文件'), previewResult.text)
|
|
167
184
|
}
|
|
168
|
-
let cancelled = false
|
|
169
|
-
const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
|
|
170
|
-
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
171
|
-
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
172
185
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
186
|
+
// 8. a running agent is force-stopped before the rewind (not refused)
|
|
187
|
+
{
|
|
188
|
+
const runningSession = buildSession('verify-running')
|
|
189
|
+
let cancelled = false
|
|
190
|
+
const running = { id: runningSession.id, session: runningSession, status: 'running', cancel: () => { cancelled = true; running.status = 'idle' } }
|
|
191
|
+
const runningResult = await call(running, '@2 chat')
|
|
192
|
+
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
193
|
+
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 9. a cancel that never quiesces aborts the rewind (timeout path)
|
|
197
|
+
{
|
|
198
|
+
const stuckSession = buildSession('verify-stuck')
|
|
199
|
+
const stuck = { id: stuckSession.id, session: stuckSession, status: 'running', cancel: () => {} }
|
|
200
|
+
const stuckResult = await call(stuck, '@2 chat')
|
|
201
|
+
check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
|
|
202
|
+
}
|
|
177
203
|
|
|
204
|
+
await rm(tmpRoot, { recursive: true, force: true })
|
|
178
205
|
console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
|
|
179
206
|
process.exit(failures === 0 ? 0 : 1)
|
package/lib/types/ledger.d.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory change ledger: records every write-class tool mutation that
|
|
3
|
-
* happened while the plugin was running, so a "rewind conversation and code"
|
|
4
|
-
* can reverse the changes that followed a target message.
|
|
5
|
-
*
|
|
6
|
-
* Scope (v0.1): the ledger covers only `write` / `edit` / `str_replace_editor`
|
|
7
|
-
* mutations observed through the tools pipeline while the plugin is loaded.
|
|
8
|
-
* Changes made by bash or external programs are not recorded and cannot be
|
|
9
|
-
* restored; a git-first snapshot layer is a v2 option.
|
|
10
|
-
*
|
|
11
|
-
* @module dsh-rewind/ledger
|
|
12
|
-
*/
|
|
13
|
-
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
14
|
-
/** One recorded write-class mutation. */
|
|
15
|
-
export interface LedgerEntry {
|
|
16
|
-
/** Tool that made the change: `write` | `edit` | `str_replace_editor`. */
|
|
17
|
-
readonly toolName: string;
|
|
18
|
-
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
19
|
-
readonly anchorSeq: number;
|
|
20
|
-
/** Display path (model/UI-facing), as resolved at record time. */
|
|
21
|
-
readonly path: string;
|
|
22
|
-
/** Full file content before the change; undefined when the file was created. */
|
|
23
|
-
readonly before: string | undefined;
|
|
24
|
-
/** Full file content after the change. */
|
|
25
|
-
readonly after: string;
|
|
26
|
-
}
|
|
27
|
-
/** Unique per-file impact of rewinding past a target message. */
|
|
28
|
-
export interface FileImpact {
|
|
29
|
-
readonly path: string;
|
|
30
|
-
/** `restore` = the file existed before the target; `delete` = created after it. */
|
|
31
|
-
readonly action: 'restore' | 'delete';
|
|
32
|
-
}
|
|
33
|
-
/** Result of one reverse restore pass. */
|
|
34
|
-
export interface RestoreOutcome {
|
|
35
|
-
readonly restored: readonly string[];
|
|
36
|
-
readonly deleted: readonly string[];
|
|
37
|
-
readonly failed: readonly {
|
|
38
|
-
path: string;
|
|
39
|
-
message: string;
|
|
40
|
-
}[];
|
|
41
|
-
}
|
|
42
|
-
/** Deletes one file by its process path (the host supplies the backend-appropriate delete). */
|
|
43
|
-
export type DeleteFile = (processPath: string) => Promise<void>;
|
|
44
|
-
/**
|
|
45
|
-
* Per-session cap on recorded entries. The ledger is intentionally bounded so
|
|
46
|
-
* an extremely long session cannot grow one entry list without limit; the
|
|
47
|
-
* oldest entries are dropped first, so rewinds to very early messages in a
|
|
48
|
-
* pathological session may lose the earliest file history (a declared
|
|
49
|
-
* tradeoff, see README).
|
|
50
|
-
*/
|
|
51
|
-
export declare const MAX_LEDGER_ENTRIES = 2000;
|
|
52
|
-
/**
|
|
53
|
-
* Append-only change ledger. Entries are recorded in commit order; a rewind
|
|
54
|
-
* replays them in reverse for the affected range. Bounded per session to
|
|
55
|
-
* {@link MAX_LEDGER_ENTRIES} (oldest dropped first).
|
|
56
|
-
*/
|
|
57
|
-
export declare class RewindLedger {
|
|
58
|
-
private readonly entries;
|
|
59
|
-
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
60
|
-
record(entry: LedgerEntry): void;
|
|
61
|
-
/**
|
|
62
|
-
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
63
|
-
* is inclusive: rewinding to a message also reverts the changes its own
|
|
64
|
-
* turn caused (the rewind cut removes that turn's assistant response and
|
|
65
|
-
* tool calls), so only changes anchored at earlier messages survive.
|
|
66
|
-
*/
|
|
67
|
-
changesAfter(targetSeq: number): readonly LedgerEntry[];
|
|
68
|
-
/**
|
|
69
|
-
* Unique per-file impact for preview. A file whose earliest affected change
|
|
70
|
-
* created it (`before === undefined`) is deleted on restore; any other file
|
|
71
|
-
* is written back to its pre-target content.
|
|
72
|
-
*/
|
|
73
|
-
impactsAfter(targetSeq: number): readonly FileImpact[];
|
|
74
|
-
/**
|
|
75
|
-
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
76
|
-
* its pre-change content back; a file that did not exist before the target
|
|
77
|
-
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
78
|
-
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
79
|
-
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
80
|
-
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
81
|
-
* @param options - session workspace cwd (relative ledger paths resolve
|
|
82
|
-
* against it, mirroring the fs tools) and an optional abort signal.
|
|
83
|
-
*/
|
|
84
|
-
restoreAfter(fs: FileSystem, deleteFile: DeleteFile, targetSeq: number, options?: {
|
|
85
|
-
cwd?: string;
|
|
86
|
-
signal?: AbortSignal;
|
|
87
|
-
}): Promise<RestoreOutcome>;
|
|
88
|
-
}
|