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 折叠。
快速开始
安装
要求 dsh 与 pnpm 在 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.json:dsh 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.patch → cordis.patch.yml)和浏览器 UI(dsh.client.platform:"web" + exports["./client"])。dsh-client-modules 会扫描 loader 行里的包,发现 dsh.client.* 声明后把 lib/client.js 喂给浏览器。host 侧不依赖 git:快照靠 snapshot.js 的目录遍历 + ledger.js 的内容寻址存储。
用法
浏览器界面
- 打开一个会话,在每条用户输入下方的时间/操作栏里会看到一个纯图标回退按钮(向后/撤销箭头)。
- 点击后弹出确认弹窗:先列出"回退会撤销的文件改动"(对比选中那一轮快照与当前工作区)。
- 确认后:插件先恢复这些文件,然后通过 DSH 官方
sessions.fork/create新建一个会话(从选中那一轮之后继续),并把这条输入的原文填回新会话输入框。 - 在新会话输入框里编辑这条输入并发送——agent 从已恢复文件、且只带选中轮之前历史的状态继续。原会话会被自动归档,从会话列表隐藏(数据与历史仍在磁盘上;可在
cordis.patch.yml里设archiveOriginalSession: false保留原会话)。
命令方式(等价,便于脚本/无 UI 调用)
/rewind <messageSeq|messageId> [rewind|list-files|dry-run]
messageSeq—— 目标用户消息的 seq;也可传messageId(会按user/message的data.message.id解析)。必须是某轮的开场用户消息。- 默认
rewind(恢复文件 + fork 新会话);list-files/dry-run只预览不执行。 /rewind list(或直接/rewind):列出本会话所有可回退的用户消息 —— 每条显示seq | turn | 是否有快照 | 消息预览,照着抄 seq 即可。- ⚠️ seq 不是"第几条消息":seq 是会话日志的原始事件流水号,
user/message、turn/start、tool/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-step且step===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)
- 采集:
TurnCheckpointCoordinator用PREPENDED 的agent/pre-step监听器,在step===1时对session.header.cwd做一次快照(walkWorkspace递归遍历,跳过.git/node_modules/台账目录;snapshot.js哈希成 sha256 树),并把内容寻址 blob + per-turn manifest 写入ledger.js的LedgerStore($DSH_HOME/rewind-ledger)。绝不阻塞/否决 agent。 - 回退:
/rewind <messageSeq|messageId>命令 + 同名/rewindHTTP 路由。messageTargetFor把目标用户消息解析为{ turn, turnStartSeq, previousTurnEndSeq }(必须是该轮的开场用户消息);- 用
diffTrees(快照entries, 当前树)得到改动文件预览; engine.applyRestoreFiles把工作区恢复到快照(删新增/恢复修改/找回删除,路径做越界防护);createConversationRestart调apiProxy.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)+ 在该会话scope上ctx.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跳过.git、node_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 即可。