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 CHANGED
@@ -2,19 +2,20 @@
2
2
 
3
3
  DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
4
4
 
5
- > 状态:v0.1.10 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
5
+ > 状态:v0.2.0 已实现(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
6
6
 
7
- ## 实现状态(v0.1.10
7
+ ## 实现状态(v0.2.0
8
8
 
9
- - ✅ host 端 `/rewind` 命令(手动单动作:不接受参数、撤回最近一条;参数化形式保留为按钮内部调用)
10
- - ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
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 规则),相对路径台账/还原指向真实文件;台账记录解析后的 display path
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
- - ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)+ 手动 `/rewind` 参数拦截
16
- - ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(14 项,含审批短路/会话 cwd 场景)
17
- - 二期:快捷键、git-first 快照式文件回退
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
- - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(按钮,或 `/rewind` 撤回最近一条),
72
+ - **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(使用消息旁的 按钮),
72
73
  效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
73
74
  Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
74
75
  可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
75
- - 手动 `/rewind` **不接受参数**:直接撤回最近一条用户消息(内容填回输入框可修改重发);
76
- 回退到更早的消息请用该消息旁的 ↶ 按钮(参数化命令保留为按钮内部调用)。
76
+ - **手动 `/rewind` 不支持**:在输入框手动输入 `/rewind`(含裸命令)会被 client 拦截并
77
+ 提示改用按钮——`/rewind` 命令仅作为按钮的内部调用通道存在。
77
78
  - **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
78
79
  上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
79
80
  可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
80
81
 
81
- ## 已知限制(v0.1
82
+ ## 已知限制(v0.2
82
83
 
83
- - 台账只覆盖插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更;
84
- bash 或外部程序的修改无法还原(二期可加 git-first 快照层)。台账按会话有界
85
- (每会话保留最近 2000 条,最旧先丢弃)。
86
- - 文件删除走 `processPath` 直删(本地 backend);sandbox/远程 backend 下还原
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
- /rewind 撤回最近一条用户消息(不接受参数;内容填回输入框可修改重发)
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 阶段)读取目标文件 before,`tools/post-execute` 记录
135
- `{ 消息锚点 seq, 文件路径(解析后), before, after }`。捕获放在 execute 而非
136
- pre-execute:**审批类插件(如 dsh-edit-approval)在 `tools/pre-execute` 返回 `ask`
137
- 会短路后续监听器**,但批准后 dispatch 阶段必然执行——共存的写操作照样入台账;
138
- 被拒绝的调用不 dispatch,不会留下 pending 残留。
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
- 台账记录解析后的 display path,preview/还原始终指向真实文件。
141
- - 回退「对话和代码」时,把目标点之后发生的变更**逆序还原**(内容写回 before、新建文件删除)。
142
- - 边界(已知限制):台账只覆盖**插件运行期间、经写类工具**的变更;bash 命令或外部程序的修改不在台账内,无法还原(二期可加 git-first 快照层)。
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` 不经过浮层**:它不接受参数,直接撤回最近一条消息;client 端在输入框拦截带参数的手动 `/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
- - 快照式文件回退(git-first)——二期(台账方案先行)。
165
+ - 快照式文件回退(整树/git-first,覆盖 bash 与外部修改)——二期(checkpoint 方案先行)。
165
166
 
166
167
  ## 目录结构(实际)
167
168
 
168
169
  ```
169
- src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute 台账(fs 动态注入)
170
+ src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute checkpoint(fs 动态注入)
170
171
  src/rewind.ts planRewind 纯函数(目标解析、surface 范围计算、候选列表)
171
- src/ledger.ts 变更台账(记录、查询、逆序还原、影响清单;按会话 cwd 解析)
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,14 项断言)
179
- tests/ rewind/ledger 单测 + 真实 dsh-session 集成测试
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\u53F0\u8D26\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
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 \u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u53EA\u80FD\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u6D88\u606F\uFF1B\u56DE\u9000\u5230\u66F4\u65E9\u6D88\u606F\u8BF7\u7528\u8BE5\u6D88\u606F\u65C1\u7684 \u21B6 \u6309\u94AE"
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 ledger-recorded file changes after the target; nothing to restore.",
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 takes no parameters \u2014 it only withdraws the most recent message. To rewind to an earlier message, use the \u21B6 button on that message."
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 REWIND_WITH_ARGS = /^\s*\/rewind\s+\S/i;
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 && REWIND_WITH_ARGS.test(textarea.value);
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, cwd, before });
316
+ pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
238
317
  }
239
- async function commitEntry(fs, ledgerFor, pending, exec, result) {
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
- const target = await resolveTarget(fs, capture.path, capture.cwd, exec.signal);
250
- if (target === void 0) return;
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\u53F0\u8D26\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
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, ledger, invocation, rawTarget, mode) {
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 fs = ctx.get("fs", false);
347
- if (fs === void 0) {
348
- restore = "\uFF1B\u672A\u627E\u5230\u6587\u4EF6\u7CFB\u7EDF\u670D\u52A1\uFF0C\u672A\u8FD8\u539F\u6587\u4EF6\uFF08\u53EF\u4EC5\u7528 chat \u6A21\u5F0F\u56DE\u9000\u5BF9\u8BDD\uFF09";
349
- } else {
350
- const outcome = await ledger.restoreAfter(fs, (processPath) => unlink(processPath), plan.targetSeq, {
351
- // Relative ledger paths resolve against the session workspace, exactly
352
- // as the fs tools resolve them (per-entry in src/ledger.ts).
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, ledger, invocation) {
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, ledger, invocation, `@${candidates[0].seq}`, "chat");
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 = ledger.impactsAfter(plan.targetSeq);
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, ledger, invocation, target, mode);
480
+ return executeRewind(ctx, store, invocation, target, mode);
416
481
  }
417
- function apply(ctx) {
418
- const ledgers = /* @__PURE__ */ new Map();
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, ledgerFor(invocation.agent.session), invocation)
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(fs, ledgerFor, pending, exec, result);
504
+ await commitEntry(store, pending, exec, result);
448
505
  } catch (error) {
449
- ctx.logger.warn(`[dsh-rewind] ledger commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
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 restricted (the guard
20
- * below): it takes no parameters a bare `/rewind` withdraws the most recent
21
- * message, and any `/rewind <args>` line is blocked with a hint pointing at
22
- * the per-message button.
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
  */
@@ -1,35 +1,58 @@
1
1
  /**
2
- * dsh-rewind host half: the `/rewind` command and the write-class change
3
- * ledger, composed as one dual-face bundle row (the browser half lives in
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 `user/message` into the session log whose `surfaceOp` replaces every
8
- * surface node after the target message with the marker. The append-only log
9
- * (and the rendered transcript) is untouched — only the model-visible surface
10
- * is cut, so the next request derives its context from the target onward.
11
- * Mode `both` additionally reverses every ledger-recorded file change that
12
- * followed the target.
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 tools-pipeline ledger hooks.
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 ledger needs
23
- * `fs`, so its hooks mount through a dynamic `ctx.inject(['fs'])` — they take
24
- * effect whenever the fs service becomes available (and never fail the
25
- * plugin's load when a deployment has no fs).
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 ledger reads/restores, mirroring the fs tools'
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.1.10",
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",
@@ -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 tools-pipeline ledger events end to end — no model, no UI.
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` (the button's call form) cuts the surface in-place (log untouched);
13
- * 4. the ledger captures through `tools/execute` (NOT pre-execute): a
14
- * pre-execute `ask` short-circuit still gets captured after approval, and
15
- * a denied call never captures (no pending leak);
16
- * 5. relative file paths resolve against the session cwd (fs-tools rule);
17
- * 6. `/rewind preview @<seq> both` reports the file impact;
18
- * 7. `/rewind @<seq> both` restores the file and reports it.
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
- /** In-memory fs double with session-cwd resolution (resolve/readText/writeText/processPath). */
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
- const content = this.files.get(target.displayPath)
39
- if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
40
- return content
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
- const writeExec = (callId, filePath, content) => ({
110
- callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),
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 exec = writeExec('c1', '/workspace/a.txt', 'rewritten')
117
- // Another plugin asks at pre-execute; the user then allows it.
118
- const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'ask', reason: 'approve me' }))
119
- check('pre-execute gate asks', gate.kind === 'ask', JSON.stringify(gate))
120
- // Approved dispatch stage runs: capture fires here.
121
- await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
122
- await fs.writeText({ targetKey: FsTargetKey('/workspace/a.txt'), displayPath: '/workspace/a.txt' }, 'rewritten')
123
- await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
124
- check('file mutated on disk', fs.files.get('/workspace/a.txt') === 'rewritten', fs.files.get('/workspace/a.txt'))
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 captures (no pending leak: nothing recorded after it)
152
+ // 5. a denied call never commits (no phantom entry)
128
153
  {
129
- const exec = writeExec('c2', '/workspace/a.txt', 'denied write')
130
- const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
131
- check('pre-execute gate denies', gate.kind === 'deny', JSON.stringify(gate))
132
- // Denied calls do not dispatch: post-execute must record nothing for c2.
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', '/workspace')
165
+ const cwdSession = buildSession('verify-cwd', wsDir)
139
166
  const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
140
- fs.files.set('/workspace/rel.txt', 'relative original')
141
- const exec = { callId: 'c3', name: 'write', arguments: { file_path: 'rel.txt', content: 'relative new' }, agent: cwdAgent, signal: aborted() }
142
- await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
143
- await fs.writeText({ targetKey: FsTargetKey('/workspace/rel.txt'), displayPath: '/workspace/rel.txt' }, 'relative new')
144
- await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
145
- // Rewind to seq 2 in the cwd session must report the cwd-resolved path.
146
- const preview = await call(cwdAgent, 'preview @2 both')
147
- check('preview resolves relative path via session cwd', preview.kind === 'success' && preview.text.includes('/workspace/rel.txt'), preview.text)
148
- const both = await call(cwdAgent, '@2 both')
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 reports the impact (rewind to seq 0 still on the surface after
153
- // the earlier withdraw — reverts the anchor-0 write)
154
- const previewResult = await call(agent, 'preview @0 both')
155
- check('preview shows file impact', previewResult.kind === 'success' && previewResult.text.includes('/workspace/a.txt'), previewResult.text)
156
-
157
- // 8. both mode restores the file
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
- // 9b. a cancel that never quiesces aborts the rewind (timeout path)
174
- const stuck = { ...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running', cancel: () => {} }
175
- const stuckResult = await registered.handler({ commandId: Symbol('cid'), agent: stuck, rawInput: '@2 chat', signal: aborted() })
176
- check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
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)
@@ -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
- }