Back to home

2501136589

dsh-rewind

DSH回退插件

Stars
1
Language
JavaScript
Created
Aug 14, 2026
Updated
Aug 14, 2026

Introduction

dsh-rewind — 会话回退插件 (Conversation Rollback for DSH)

一个 100% 开源的 DeepSeek Harness(dsh)插件 bundle,借鉴 opencode / dsh-turn-rewind 的 OpenCode 式架构:为非 git 场景实现内容寻址的文件快照台账,并通过 DSH 官方 sessions.fork 重开一个新会话来回退对话——恢复文件 + 从选中的那一轮输入重新续起。

  • 回退重开(fork):每条用户消息下方出现一个纯图标回退按钮。点击后弹出确认弹窗,预览"回退会撤销的文件",确认后:先恢复这些文件,再通过 DSH 官方 sessions.create / sessions.fork 新建一个会话(从选中的这一轮输入之后继续),并把这条输入的原文填回新会话的输入框,方便你改完重新发送。原会话会自动归档(从会话列表消失;DSH 没有"删除单个会话"的 RPC,归档是官方机制——数据与历史仍在磁盘上,新会话继续可回退)。
  • 内容寻址快照(非 git):每个 agent turn 第一次 step 前,用递归目录遍历(不用 git)把工作区哈希成 sha256 的内容寻址快照,存进 $DSH_HOME/rewind-ledger 台账目录(内容寻址的 blob + per-turn manifest 都放在这里;rewind 的第一版原型数据与之无关,已废弃);回退时把工作区恢复到那个 turn 的快照。支持新增/删除/修改/权限/文本与二进制。

为什么是"重开新会话"而不是"原地折叠":DSH 的会话日志是只读追加、不可物理截断;而且 DSH 浏览器端不会根据第三方插件追加的普通 user/message + surfaceOp.replace 折叠对话。所以采取与 turn-rewind 同款的做法——恢复文件后 fork 一个新会话(原会话归档、从列表隐藏)。浏览器打开新会话时天然只显示到选中的那一轮为止,可靠且不需要 DSH 内部支持第三方 surface 折叠。


快速开始

安装

要求 dshpnpm 在 PATH 上。

REM 说明:%USERPROFILE% 是当前用户的 home(等价于 PowerShell 的 $env:USERPROFILE)。
REM 以下命令在 cmd 与 PowerShell 中均可运行(PowerShell 请把 %USERPROFILE% 换成 $env:USERPROFILE)。

# 1) 克隆
cd %USERPROFILE%
git clone https://github.com/2501136589/dsh-rewind.git
cd dsh-rewind

# 2) 安装进 web profile
#    推荐(无空格路径):
dsh plugin --profile web add .
#    若路径含空格导致相对锚定解析出错,则进 profile 目录直接 pnpm:
cd %USERPROFILE%\.dsh\profiles\web
pnpm add "link:<dsh-rewind 的绝对路径>"

无论哪种方式,最后都要让 @xiaoxin/dsh-rewind 出现在 web profile 的 package.json 里:

"dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@xiaoxin/dsh-rewind" ] } },
"dependencies": { "@xiaoxin/dsh-rewind": "link:<路径>" }

注意:bundle 行必须用 - insert: 包裹在 bundle 的 cordis.patch.yml 里(本仓库已配好),否则 loader 会把它当“按 id 覆盖已有 entry”而静默忽略,导致按钮不出现。

然后完全停掉再启动 dsh web(装载器与浏览器客户端 bundle 在新进程里才注册),再 Ctrl+F5 强刷。成功后,每条已完成的助手消息旁会出现一个纯图标回退按钮。

通过 npm 安装(可选)

发布到 npm 后,可用 dsh plugin --profile web add @xiaoxin/dsh-rewind 直接安装 package 名。

常见问题

  • declares no dsh.bundle in its package.jsondsh plugin 没把这个包识别为 bundle。确认 package.json 里有 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
  • client bundle not found:确认 exports["./client"] 指向真实存在的 lib/client.js
  • 重启了但没有回退按钮:先确认 profile 的 dsh.profile.bundles 里有 @xiaoxin/dsh-rewind;再确认 cordis.patch.yml 用的是 - insert: 包裹 loader 行(本仓库已配好)。然后完全停掉再启动 dsh web(不是只刷新页面),再 Ctrl+F5。

目录结构

dsh-rewind/
├─ package.json          # bundle 清单:dsh.bundle.patch + dsh.client(web)
├─ cordis.patch.yml      # 装载 host 插件的 loader 行(必须用 - insert:)
├─ lib/
│  ├─ index.js           # host 侧入口:装配快照采集、/rewind 命令与 HTTP 路由
│  ├─ snapshot.js        # 非 git 工作区遍历 + sha256 内容寻址快照/差异
│  ├─ ledger.js          # 内容寻址 blob/manifest 台账($DSH_HOME/rewind-ledger)
│  ├─ host-rewind.js     # turn 采集、消息→fork 边界解析、恢复、HTTP handler
│  ├─ client.js          # 浏览器侧:用户消息回退按钮 + 重开会话
│  └─ types/*.d.ts       # 类型声明
├─ test/                 # smoke 测试(node 直跑,无外部依赖)
├─ LICENSE               # MIT
└─ README.md

一个包同时承载 host 逻辑(dsh.bundle.patchcordis.patch.yml)和浏览器 UI(dsh.client.platform:"web" + exports["./client"])。dsh-client-modules 会扫描 loader 行里的包,发现 dsh.client.* 声明后把 lib/client.js 喂给浏览器。host 侧不依赖 git:快照靠 snapshot.js 的目录遍历 + ledger.js 的内容寻址存储。


用法

浏览器界面

  1. 打开一个会话,在每条用户输入下方的时间/操作栏里会看到一个纯图标回退按钮(向后/撤销箭头)。
  2. 点击后弹出确认弹窗:先列出"回退会撤销的文件改动"(对比选中那一轮快照与当前工作区)。
  3. 确认后:插件先恢复这些文件,然后通过 DSH 官方 sessions.fork/create 新建一个会话(从选中那一轮之后继续),并把这条输入的原文填回新会话输入框
  4. 在新会话输入框里编辑这条输入并发送——agent 从已恢复文件、且只带选中轮之前历史的状态继续。原会话会被自动归档,从会话列表隐藏(数据与历史仍在磁盘上;可在 cordis.patch.yml 里设 archiveOriginalSession: false 保留原会话)。

命令方式(等价,便于脚本/无 UI 调用)

/rewind <messageSeq|messageId> [rewind|list-files|dry-run]

  • messageSeq —— 目标用户消息的 seq;也可传 messageId(会按 user/messagedata.message.id 解析)。必须是某轮的开场用户消息
  • 默认 rewind(恢复文件 + fork 新会话);list-files / dry-run 只预览不执行。
  • /rewind list(或直接 /rewind):列出本会话所有可回退的用户消息 —— 每条显示 seq | turn | 是否有快照 | 消息预览,照着抄 seq 即可。
  • ⚠️ seq 不是"第几条消息":seq 是会话日志的原始事件流水号user/messageturn/starttool/call 等所有事件共用一条递增计数器,所以用户消息的 seq 往往不连续(例如 1, 47, 92…)。/rewind list 会直接给出可用值;浏览器按钮弹窗里显示的也是这套 seq。

回退的语义细节(务必了解)

  • 会话与文件是两套独立的回退:文件靠内容寻址快照回退;对话靠官方 sessions.fork/create 重开新会话。两者各自可靠,不需要 DSH 内部支持第三方表面折叠。
  • 重开新会话:确认后,插件调用 apiProxy.sessions.fork({ sessionId, atSeq: 选中轮之前的 turn/end })(首轮则 sessions.create({ cwd }))新建一个会话,其历史天然只到选中的那一轮为止;然后调用 apiProxy.workspace.archiveSession 归档原会话(默认,可在 cordis.patch.yml 关掉)——DSH 没有"删除单个会话"的 RPC,归档会把原会话从侧栏列表和搜索中排除,但它的数据与台账快照仍在磁盘上,分叉后的新会话继续回退不受影响。浏览器打开新会话即看到折叠后的效果。
  • 采集边界 = turn:每个 agent turn 的第一次 step(agent/pre-stepstep===1)前,对工作区做一次快照(哈希成 sha256 内容寻址树存进台账)。这是 opencode 式"step 前打 baseline",而不是每条用户消息/每次写入。
  • 内容寻址、几乎不复制:每个文件按内容 sha256 去重存储(相同内容只存一份),台账只在 $DSH_HOME/rewind-ledger/ 下按工作区 sha256 分目录。支持新增/删除/修改/权限/文本与二进制;超大或超总量会明确失败,不静默漏。
  • 只能回退到"开启采集"之后的 turn:启用插件之前的轮次没有快照,无法回退,会明确提示。
  • 快照边界与对话边界一致:一个 turn 的"开场用户消息"才能在 Web 上回退(对应它的快照 + 它之前的 turn/end 作为 fork 点)。
  • 分叉会话也能继续回退(血缘解析):fork 出来的新会话会原样拷贝父会话的事件(seq 逐条一致)直到 header.seedLength。在这些"拷贝来的消息"上回退时,插件沿 header.parentSession 血缘链向上找到真正录制这些快照的会话,用它的台账(checkpointOwnerFor)。所以 A→回退→A2,在 A2 里继续回退 A 的消息、甚至回退 A2 自己续写的新轮次,都成立;消息在血缘链上每一层的 seedLength 之内,就继续向上找。
  • 文件无改动也能回退:回退的核心是"重开会话";当工作区已经与该快照一致时,弹窗仍提供"确认回退"(只做 fork + 填回原文),不会卡死。
  • 回退只动本会话自己的文件(跨会话隔离):多个会话共享同一工作区时,快照是"整个目录"的状态。回退范围被限制为该会话自己碰过的文件——从会话事件里的 fs 写工具调用(write/edit 一族,tool/call)∪ 该会话连续快照之间的漂移(覆盖 shell 副作用)。所以 B 会话回退时,A 会话后来改的文件不会出现在预览里、也不会被还原。会话事件完全取不到时才回退到"全量差异"(scope=workspace,旧行为)。

架构

Host 侧(lib/index.js + host-rewind.js + snapshot.js + ledger.js

  • 采集TurnCheckpointCoordinatorPREPENDEDagent/pre-step 监听器,在 step===1 时对 session.header.cwd 做一次快照(walkWorkspace 递归遍历,跳过 .git/node_modules/台账目录;snapshot.js 哈希成 sha256 树),并把内容寻址 blob + per-turn manifest 写入 ledger.jsLedgerStore$DSH_HOME/rewind-ledger)。绝不阻塞/否决 agent。
  • 回退/rewind <messageSeq|messageId> 命令 + 同名 /rewind HTTP 路由。
    1. messageTargetFor 把目标用户消息解析为 { turn, turnStartSeq, previousTurnEndSeq }(必须是该轮的开场用户消息);
    2. diffTrees(快照entries, 当前树) 得到改动文件预览;
    3. engine.applyRestoreFiles 把工作区恢复到快照(删新增/恢复修改/找回删除,路径做越界防护);
    4. createConversationRestartapiProxy.sessions.fork(有 previousTurnEndSeq 时)或 sessions.create(首轮;先经 workspace.list 解析源会话工作区、把 workspaceId 带进载荷,避免生成"无工作区"的新会话)得到新会话 id。

浏览器侧(lib/client.js

  • apply(ctx) 注入 conversation.session.header.actions 槽,用一个 session 作用域组件经过 React portal 把回退按钮放进每条用户消息的 action 行(data-chat-flow-kind="user" + data-chat-anchor-key + data-time-hover-root)。
  • 点击弹窗 → GET /rewind?sessionId=..&messageSeq=.. 预览文件改动 → 确认后 POST /rewind 恢复文件并 fork 新会话。
  • 成功后 openSessionWithDraft: ctx.sessions.open(新会话id) + 在该会话 scopectx.conversation.input.for(scope).setDraft(原文),把这条输入原文填回新会话输入框。

为什么走官方 sessions.fork + 同源 HTTP,而不是 session.command + 表面替换

之前版本想走 session.command + surfaceOp.replace 原地折叠,但 DSH 浏览器端不渲染第三方 surface-replace,聊天永远不动。改用官方的 sessions.fork(新建会话)是平台原生支持、必定在浏览器端正确显示历史截断的方式;同源 HTTP 路由让浏览器能预览恢复前的文件改动,无需第三方 Remote 命名空间。


局限与后续

  • 重开会话而非原地折叠:回退会新建一个会话,并把原会话归档(从列表隐藏,默认行为,可用 archiveOriginalSession: false 关闭)。DSH 目前没有"删除单个会话"的 RPC,也没有在 UI 里取消归档的入口——归档后原会话从侧栏/搜索消失,但其数据仍在磁盘上(~/.dsh/sessions/...)。需要"物理删除"时,那要 DSH 平台支持第三方删除会话,超出本插件(也是一切第三方 bundle 的边界)。
  • 快照边界 = turn:只能在"开启采集之后"的轮次回退;首轮没有上一轮 turn/end,会走 sessions.create首轮回退会先通过 workspace.list 找到源会话所在的工作区,把新会话挂进同一工作区——不会出现"没有工作区"的新会话。
  • 工作区遍历的取舍walkWorkspace 跳过 .gitnode_modules 和台账目录,不回退这些内容;超大文件/超总量会明确失败而不是静默漏。
  • 跨会话隔离是启发式的:回退范围来自 fs 写工具调用 + 连续快照漂移。若 agent 用 shell 命令在最后一个 turn 里改文件(没有后续快照可对、也没有写工具调用),这类改动可能不被归因;会话事件缺失时整体退回"全量差异"(不隔离)。两个会话改过同一个文件时,后回退的一方可覆盖另一方的该文件改动(真正的冲突,无法自动裁决)。
  • 并发/漂移:演示级实现没做 turn-rewind 那样的恢复点细锁、rescue 点、hash 验证后 rollback 与操作日志。若用于生产,建议参考 dsh-turn-rewind 补齐这些护栏(它已含完整的 Change Ledger)。

台账维护(scripts/repair-ledger.mjs

内容寻址台账是只增不减的:采集绝不删除 blob(避免"UI 还列着某轮、blob 已被 GC"的 BLOB_NOT_FOUND)。需要瘦身时手动跑一次修复:

node scripts/repair-ledger.mjs            # 默认修 $DSH_HOME/rewind-ledger(无 DSH_HOME 则 ~/.dsh/rewind-ledger)
node scripts/repair-ledger.mjs <其他目录> # 指定台账目录

它会删除:① 包含 node_modules/.git 路径的旧时代污染 manifest;② 引用了缺失/损坏 blob 的 manifest;然后 collectGarbage 清掉不再被任何 manifest 引用的 blob。

测试

cd dsh-rewind
node test/client-smoke.mjs      # 客户端:槽注册 + openSessionWithDraft 回填 helper
node test/render-smoke.mjs      # 组件渲染:纯图标按钮 + Modal
node test/host-smoke.mjs        # host:快照采集/差异/恢复 + messageTargetFor + fork
node test/host-undo-smoke.mjs   # 端到端:恢复新增/删除/修改 + blob 哈希校验 + fork
node test/host-fork-lineage-smoke.mjs # fork 血缘:A2/A3 里回退父会话消息的台账解析
node test/host-scoped-rewind-smoke.mjs # 跨会话隔离:B 回退不碰 A 改过的文件
# 或一次跑全部:
npm test   # = 上面 6 个脚本依次执行

测试在临时工作区 + 临时 DSH_HOME 下直跑,不触网、不读真实 DSH 环境,node 即可。


License

MIT