# dsh-rollback [![npm version](https://img.shields.io/npm/v/dsh-rollback.svg)](https://www.npmjs.com/package/dsh-rollback) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [English](README.md) | 中文 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的文件变更回滚插件:将 `write`/`edit` 变更在其结果中报告的改前映像记录为检查点,并对黑盒 `bash`/`run_code` 调用做前向快照使其文件变更可 diff,把每个改前映像存入工作区 git 对象库或快照存储,并通过面向模型的 `rollback_files` 工具和面向人的 `/rollback` 命令提供还原。它不注册任何服务,也不改动循环代码——捕获搭在文档化的 `tools/*` 扩展点上(`tools/result` 观察事件与 `tools/execute` around 钩子),还原直接写文件(绝不经过 fs 策略 seam 或沙箱,因为撤销一次变更不应被该变更当初通过的策略所门控)。 ## 安装 本包是可安装的 **bundle**(声明了 `dsh.bundle`),直接接入 profile,无需改动 harness。你只需要一个 `dsh` CLI;运行时 peer 包(`@deepseek-ai/dsh-tools`、`@deepseek-ai/cordis` 等)从 dsh 安装本身解析,无需额外安装任何东西。 ### 前置条件 - 机器上有 `dsh` CLI(`dsh plugin` 内部调用 `pnpm`,所以 `pnpm` 需在 `PATH` 中)。 - 一个要安装进去的 profile——下面的 `demo` 首次使用时自动初始化,可换成任意名字。 ### 从 npm 安装(推荐) ```sh dsh plugin --profile demo add dsh-rollback ``` 其他来源用法相同: ```sh # 直接从 git 安装(源码由 prepare 脚本构建;建议锁定 commit) dsh plugin --profile demo add github:you/dsh-rollback# # 或从本地 tarball dsh plugin --profile demo add ./dsh-rollback-.tgz ``` ### 验证安装 1. `$DSH_HOME/profiles/demo/`(`$DSH_HOME` 默认为 `~/.dsh`)下的 profile manifest 中,`dependencies` **和** `dsh.profile.bundles` 都会出现 `dsh-rollback`——因为包声明了 `dsh.bundle`,reconciler 会自动加入。等价的手动补丁层行: ```yaml - id: rollback name: dsh-rollback config: mode: auto # auto | git | snapshot storeDir: '' # '' = /rollback maxRecords: 200 gitPath: git ``` 2. 启动一个会话。以下任一现象都说明插件已生效: - 模型的工具列表里能看到 `rollback_files`; - 在还没发生任何变更时输入 `/rollback`,得到 `rollback: nothing to restore`(而不是"未知命令"报错)。 ## 使用教程 ### 30 秒快速上手 1. 打开一个工作目录在 git 仓库内的会话。 2. `write` 一个文件 `notes.md`,内容为 `hello`。 3. 再次 `write` 它为 `goodbye`——第一次的内容已被静默记录。 4. 对模型说"把你刚覆盖的文件还原"(它会调用 `rollback_files`),或自己输入 `/rollback`。 5. 查看 `notes.md`:内容又变回 `hello`。 ### 面向人 — `/rollback [count]` 在聊天输入框输入(base `web` 与 `headless` profile 挂载了命令所需命令注册表): - `/rollback` —— 撤销本会话工作目录中最近一条被捕获的变更; - `/rollback 3` —— 撤销最近三条。 输出逐文件列出还原动作: ```text rollback: restored 2 file mutation(s): restored /ws/src/lib/parse.ts deleted /ws/src/lib/generated.ts ``` 人手动回滚还会**告诉模型**。命令的生命周期(`command/run`/`command/done`)是 log-only、永不进入模型上下文的,因此插件会为下一个 pre-step 排入一条面向模型的 notice(走 `Agent.inject`):逐条列出它重写或删除了哪些路径,并说明模型手里的这些文件副本已经过期。没有这条链路时,人的回滚会在模型背后改写文件,而模型的 transcript 仍在描述回滚前的世界——它甚至会继续针对已经不存在的路径干活。设 `notifyModel: false` 可让手动回滚保持静默。 ### 面向模型 — `rollback_files` 新增一个面向模型的工具 `rollback_files {count}`(无提示词章节)。它用于让模型撤销自己犯下的 `write`/`edit` 错误,而不是回头麻烦用户。还原限定在调用方会话的工作目录内,输出为逐文件摘要——不会回显还原后的文件内容。 ### 捕获范围 同一 store 有两条捕获路径:携带 `before` 改前映像的成功 `write`/`edit` 工具结果,以及根 `bash`/`run_code` 调用造成的文件变更(在 git 仓库内前向快照后 diff,见[行为](#行为))。通过 `str_replace_editor`、裸子进程或非 git 仓库内的 `bash` 调用产生的变更没有可恢复的改前映像,不会被捕获。会话只能还原位于自身工作目录下或其自身的记录。 ## 效果演示(前后对比) 一次 `write` 覆盖、一次撤销。同一个文件,四种状态: | 步骤 | 动作 | `notes.md` | |---|---|---| | 1 | 初始状态 | `hello` | | 2 | 模型 `write` 写入错误编辑——改前映像被捕获 | `goodbye` | | 3 | 模型调用 `rollback_files {"count": 1}` | (透明) | | 4 | 逐字节还原 | `hello` | 完整 transcript: ```text # 1. 初始状态 $ cat /ws/notes.md hello # 2. 模型覆盖文件;tools/result 携带改前映像 "hello", # 捕获监听器将其记录进 git 对象库 > tool/call write {"path": "/ws/notes.md", "content": "goodbye"} > tool/result {"path": "/ws/notes.md", "before": "hello", ...} # 3. 模型意识到写错了,撤销该变更 > tool/call rollback_files {"count": 1} > tool/result "rollback: restored 1 file mutation(s): restored /ws/notes.md" # 4. 还原为变更前内容 $ cat /ws/notes.md hello ``` 底层实现:`git hash-object -w` 将改前映像写入 git 对象库(零索引/分支/工作树污染),同时向持久化 `manifest.jsonl` 追加一行——因此重启后同一撤销依然可用。 ## 工作原理 ```mermaid flowchart TD A["write/edit 工具结果"] --> B["tools/result 观察事件"] B --> C{结果带 before 改前映像?} C -- 否 --> X[忽略] C -- 是 --> S[CheckpointStore 捕获] A2["bash / run_code 调用"] --> B2["tools/execute 前向快照 + diff"] B2 --> C2{工作区是 git 仓库?} C2 -- 否 --> X2[跳过并告警] C2 -- 是 --> S S --> G{工作区是 git 仓库?} G -- 是 --> BLOB["git hash-object -w 存 blob"] G -- 否 --> P["写 storeDir/snapshots/ 快照"] S --> MF["追加 manifest.jsonl"] U["模型调 rollback_files / 用户 /rollback"] --> RS["restore 按 session.cwd 作用域"] RS --> RR["git cat-file / 快照 / 删除文件"] ``` 上图的完整示例与前后对比 transcript 见[效果演示](#效果演示前后对比)。 ## 插件(namespace: `rollback`) 函数/命名空间插件(`name` / `inject` / `Config` / `apply`),不是服务。它与 `dsh-tool-call-timeout-policy` 同属循环卫生 guard 家族:在文档化的 `tools/*` 扩展点之上叠加安全网,而非触碰 agent 循环。 ### Config | 键 | 类型 | 默认值 | 含义 | |---|---|---|---| | `mode` | `'auto' \| 'git' \| 'snapshot'` | `'auto'` | `git` 将每个改前映像记录为 git blob(需要仓库;非仓库路径大声失败且不捕获任何内容);`snapshot` 始终将改前映像复制到 `storeDir/snapshots/` 下;`auto` 在工作区是仓库时按文件选用 git,否则用快照。 | | `storeDir` | string | `''` | 持有持久化 `manifest.jsonl` 与 `snapshots/` 的根目录。为空时解析为 Harness home 下的 `rollback`。 | | `maxRecords` | number | `200` | 每个 store 内存记录的上限;超出后丢弃最旧的(持久化 manifest 保留全部)。 | | `gitPath` | string | `'git'` | Git 可执行文件名或绝对路径。 | | `mutationTools` | string[] | `['bash', 'run_code']` | 需要前向快照的工具名——不带 `before` 改前映像的黑盒变更调用。只对根派发做快照(Code Mode 嵌套子调用由它们的 `run_code` 父级覆盖),且仅在 git 仓库内。 | | `notifyModel` | boolean | `true` | 人手动 `/rollback` 之后注入一条面向模型的 notice,让模型知道文件在它掌控之外被改动了。模型自己调 `rollback_files` 不需要这条通知(其调用与结果本来就在 transcript 里)。 | ### 行为 **捕获。** `tools/result` 监听器将成功的 `write`/`edit` 结果转换为检查点:结果的 `before` 字段即改前内容(`null` 记录文件原本不存在)。`blob` 改前映像通过 `git hash-object -w --stdin` 写入文件所在仓库(向上探测 `.git` 发现仓库根,按目录缓存)——零索引/分支/工作树污染,由 git 自身内容寻址并去重。每条记录以一行 JSONL 追加到 `manifest.jsonl`;插件加载时的 store 回放恢复内存列表,因此还原在重启后依然可用。只捕获绝对本地展示路径;相对或远程展示路径(非本地文件系统后端)被忽略。 **前向快照(bash / run_code)。** `tools/execute` around 钩子会在根 `bash`(或 `run_code`)调用执行前快照工作区。候选集由 git 一次调用枚举(`git ls-files -co --exclude-standard`):已跟踪文件 + 未被忽略的未跟踪文件——由忽略规则而非手写遍历决定范围,构建产物与 vendor 目录因此零成本。`.git` 与 `node_modules` 永不快照(也因此永不被还原),插件自身 store 目录也被排除。随后所有候选文件在一次批量调用中保留为原始 git blob(`git hash-object -w --no-filters --stdin-paths`):`--no-filters` 保证改前映像字节级精确(否则 git 会做 CRLF/属性归一化,还原时改写换行符而不是复现文件);批量调用把「每文件一个进程」降为「每次快照固定几个进程」。`mtime + size` 快速路径跳过自上次快照以来未变化的文件。调用结束后重新枚举候选集并 diff,每个变更文件都会被记录为检查点——修改或删除的文件保留变更前 blob,新建文件记录为 `absent`。快照限定在会话工作目录内且要求 git 仓库,否则跳过并告警。Code Mode 嵌套子调用由它们的 `run_code` 父级覆盖,因此一次变更永不会被重复记录。 **还原。** `restore(count, under)` 重新物化最近的 `count` 条路径位于 `under`(调用方 agent 的会话工作目录)之下或其自身的记录:`blob` 通过 `git cat-file blob `,`snapshot` 从 `storeDir/snapshots/`,`absent` 则删除文件。写入是原子的(临时文件 + rename)并创建父目录。被还原的记录从内存列表移除;manifest 保持只追加,因此重启会回放同样的记录,后续还原会重新应用完全相同的改前映像(幂等,不会双重撤销)。 **暴露。** - `rollback_files` 工具——面向模型的还原,参数 `count`(整数,默认 1)。注册在 `ctx.tools` 上;非并发安全。当调用执行没有会话工作目录时拒绝执行。 - `/rollback [count]` 命令——对接收方 agent 的会话执行同样的还原;命令子组件仅在组合了命令注册表时激活(base `web` 与 `headless` profile 挂载 `dsh-commands`)。非空还原之后它注入一条面向模型的 notice(见[模型体验](#模型体验));没有 inbox 的 agent、或被拒绝的注入,都不会影响命令自身的返回结果。 ### 为什么用 git,为什么直接 spawn Git blob 与 ccAgent 使用同一机制:`hash-object -w` 写入改前映像而不触碰索引、引用或工作树;`cat-file` 原样还原字节;未被引用的 blob 由 git 自身的 gc 回收。Git 通过 `node:child_process` 直接 spawn(绝不经过 `ctx.shell` 或 `ctx.subprocess`):还原是刻意的系统级撤销,因此不能被它所撤销的沙箱或 shell 策略所限制。 ## 模型体验 ### 面向模型的还原工具 #### 模型所见 本插件新增一个面向模型的工具 `rollback_files`(整数参数 `count`,字符串输出),无提示词章节。它不改变任何其他工具的 schema 或系统提示词。 `/rollback` 命令本身永不进入模型——命令生命周期是 log-only——但**完成的人手动回滚会**:插件注入一条 plugin 来源的 `notice` 消息,逐条列出它 restored/deleted 的路径,并附带"模型手里这些路径的副本已过期、必须重新读取"的说明。注入的上下文排在下一个 pre-step,但不唤醒 driver,所以空闲会话会在下一次请求时带上它。 #### Token 影响 正常运行零 token。一次 `rollback_files` 调用会加入其小型的工具/结果对;一次人手动 `/rollback` 会加入一条短 notice(一行抬头、每路径一行、一行指示;绝不回显文件内容)。捕获本身对模型不可见。 #### KV Cache 影响 只追加;新增的工具 schema、结果与 notice 都跟随可复用请求前缀,不使现有 KV-cache 条目失效。 ## 已知限制与延后工作 - **`str_replace_editor` 与裸子进程不被记录** —— `bash`/`run_code` 的文件变更已由前向快照捕获,但仅在 git 仓库内(非仓库工作区跳过快照并告警),且 `bash` 调用只快照会话工作目录,它在别处改动的文件不会被捕获。plan 范围批量备份(执行前捕获 plan 触碰的每个文件)是对应的泛化方向,已延后。 - **前向快照字节级精确,`write`/`edit` 仍限文本** —— `bash`/`run_code` 的改前映像以原始字节保留(`--no-filters`),因此二进制文件与 CRLF 文件都能精确往返;`write`/`edit` 路径仍然携带工具自身的 `before` 字符串,按 fs 工具的契约是文本。 - **被忽略的路径永不被捕获** —— 前向快照的候选集来自 git 的忽略规则,因此**既未跟踪、又被排除**的路径(`dist/`、`build/`、`coverage/` 等)不会被 `bash`/`run_code` 记录为检查点,也无法回滚;git 已跟踪的文件即使被 `.gitignore` 匹配,也照样会被捕获。 - **大仓库的首次快照要付 O(文件数) 成本** —— 前向快照为每个候选文件存一个 git blob,因此一个仓库在某会话中的首次快照主要开销在 git 写 loose object 上。批量调用消除了「每文件一个进程」(每次快照固定几次 git 调用),但字节仍需每文件读一次、存一次;同一会话中之后的快照复用 `mtime + size` 缓存。这一首次成本随文件系统与杀毒软件对 `.git/objects` 的扫描而波动。 - **一个批量哈希无法寻址的路径会让该次快照失效** —— 路径以换行分隔传给 git(`--stdin-paths`),因此文件名中含换行(POSIX 合法;NTFS 拒绝)会让整批失败,而批量失败会中止整次快照而不是跳过该文件:只要工作区里存在这样的路径,该仓库的 `bash`/`run_code` 变更就**全部**不被捕获,且每次调用都会告警。 - **还原限定工作区** —— 调用方 agent 会话工作目录之外的记录永不被该调用方还原;没有跨目录或全局还原入口。 - **还原不做陈旧校验,直接覆盖** —— 改前映像被无条件写入。若文件在该变更被捕获之后又被改过(人手动编辑、另一个 agent、后续命令),还原会**静默丢弃**这些更新的内容:既没有冲突检测,也不备份被替换掉的状态。 - **工作目录相同的多个会话可互相撤销** —— 作用域是**目录**而不是会话。记录里不带会话标识,因此一个会话里的 `/rollback` 或 `rollback_files` 也会选中任何其他同工作目录会话(子 agent、另一个窗口)产生的变更。 - **手动回滚的 notice 只是提示,不是约束** —— 它告知模型,但不限制模型。真正阻止模型覆盖掉回滚结果的是 harness 本身:还原会替换文件(新 inode/mtime/ctime),于是 `fs-observation-policy` 的版本守卫会把模型对同一路径的下一次 `write`/`edit` 判为 stale 并强制重读。没挂该策略的 profile 只剩 notice 这一层;`notifyModel: false` 会连 notice 一起去掉。 - **只追加 manifest,无修剪** —— 被还原的记录仍留在 `manifest.jsonl` 中并在回放时重新出现(幂等重复还原,不会双重撤销),但长期运行的 harness home 会无压缩地增长。回放还会把内存列表裁剪到 `maxRecords`,因此重启后**只有最新的 `maxRecords` 条可还原**,尽管文件里保留了全部记录。 - **多进程共享同一 store 时 `seq` 会冲突** —— 序号是进程内的(回放到 manifest 末尾后自增)。两个 harness 进程共用一个 store 目录会写出重复序号,破坏同伴插件断言的严格递增不变量,也让「按条数还原时丢弃哪些记录」变得含混。 - **捕获的持久性是尽力而为** —— manifest 追加既不做 fsync、也不被等待(捕获挂在观察事件上 fire-and-forget),因此变更与落盘之间发生崩溃可能丢掉最新记录,且只体现为一条告警。 - **git gc 可能回收长期 blob** —— 默认 gc 在保留窗口后回收未被引用的对象;早于该窗口的检查点可能无法还原。保活 ref 命名空间已延后。 - **失败时不自动还原** —— `restoreOnFailure` 在 v1 中刻意不提供;自动还原需先将失败归因到具体变更。 ## 开发 ```sh pnpm install # peer 包从 npm 发布版解析 pnpm run build # tsdown -> lib/(ESM + d.mts),自包含构建 pnpm test # vitest,23 个 store 级测试 + 7 个 notice 测试 ``` ## 许可证 MIT