@240xu/dsh-message-ops

中文文档
0.19.0发布于 2026/11/07MITES Module零依赖近 30 天下载 5475

DSH web 插件:消息级回滚 / 删除 / 恢复 / 分叉 / 编辑重发(补 dsh 官方没有的部分)。

中文使用文档
文档目录77 节
  1. 10 秒速查
  2. 官方没有、本插件提供的
  3. 为什么不重复造轮子(0.9.0 的取舍)
  4. 用法
  5. 0.12.0 · 动作收敛到用户消息(opencode 对齐)
  6. 安装
  7. 安全设计
  8. 端点
  9. 0.11.5 · restore 进度落盘 + turnAligned 可观测
  10. 0.11.4 · turn 游标对齐真正生效(会话损坏 P0 续)
  11. 0.11.3 · 区间编码、回填落盘与会话损坏修复
  12. 0.11.2 · 合成 turn 竞态与历史日志兼容修复
  13. 0.11.0 · 方向 A 面板 + 会话回收站
  14. 0.2.4 UI GAP(ux-scout dsh-ui-spec G-M1)
  15. 0.2.3 格式兼容(compat-audit + 用户实测紧急修复)
  16. 0.2.2 前端收尾(评审 M1/M2)
  17. 0.2.1 评审修复(架构评审 arch-review)
  18. 0.2.0 新增
  19. Agent 工具 message_ops
  20. 导出 Markdown
  21. 回滚恢复(restore,重放语义)
  22. 开发
  23. License
  24. 0.3.0 · 用户 UI 回撤体系(官方槽集成)
  25. 0.3.1 · 槽按钮重构(opencode 式一键 + 去重)
  26. 0.3.2 · 图标规范化
  27. 0.4.0 · opencode 式回撤深化(composer 回填 + 回撤 dock)
  28. 0.4.1 · dock 修复:统计口径 + 原生观感
  29. 0.4.2 · dock 像素级对齐 opencode + 回撤生命周期修正
  30. 0.4.3 · dock 崩溃修复(Hooks 规则违规)
  31. 0.5.0 · 「按钮没有用」根因修复 + 消息引用
  32. 0.5.1 · 修复 0.5.0 客户端激活失败
  33. 0.5.2 · Bug 猎场第一轮修复(P0×1 + P1×3 + P2×6)
  34. 0.5.3 · 「打开即 Running」误报修复(实机 P0 级)
  35. 0.5.4 · 「点回撤打死整个 dsh」P0 修复(包装实测 exit=1 定位)
  36. 0.5.5 · v4 准入第 3 关: message.id 非空字符串
  37. 0.5.6 · dock 死按钮修复:空可重放区间改优雅停用
  38. 0.5.7 · 第四层根因:store-miss 会话的磁盘追加路径
  39. 0.6.0 · opencode 对齐:回滚长在「我的消息」上 + 贴条化恢复 UI
  40. 0.7.0 · 用户消息回滚按钮回归「官方动作行」(修布局乱/点不了)
  41. 0.8.0 · 按轮步进恢复(对齐 opencode 展开行语义)
  42. 0.8.1 · discard 停用面 + 用户会话实查结论
  43. 0.9.0 变更清单
  44. 移除(与官方重复或无入口)
  45. 修复(均为浏览器实测发现,静态审查无法发现)
  46. 契约测试
  47. 事件形状的三条硬规则(写错就是整份日志报废)
  48. 0.9.1 / 0.9.2 · 回滚「真的生效了」
  49. 问题:回滚对用户是无效的(0.9.0 及以前)
  50. 解法:渲染层按 node-key 精确映射
  51. 三个踩过的坑(都有回归测试锁住)
  52. 实测结果
  53. 已知限制
  54. 0.9.3 · /undo —— 复刻 opencode 的招牌交互
  55. 注册路径踩过的四个坑
  56. 目标必须挑 surface 上的那条
  57. 实测
  58. 0.9.4 · 修「正文几乎空白」
  59. 症状
  60. 根因
  61. 量化验证(两条真实会话)
  62. 修复
  63. 实测
  64. 历史事故记录(从源码迁移)
  65. #1 src/client.js:2-14 · 客户端模块头(含已过时的「整页刷新」)
  66. #2 src/client.js:1024-1025 · 0.6.0「absolute 覆盖压字」教训
  67. #3 src/client.js:1074-1097 · injected 正则两轮事故 + 120 份日志普查
  68. #4 src/client.js:1250-1256 · 隐藏判据「revertFences → visible → …
  69. #5 src/client.js:1753-1755 · 「5 个入口只有 2 个回填」
  70. 已在本 README 其它章节(源码同步压缩成指针,未新增重复内容)
  71. 0.18.0 · 行内操作三项修复 + 面板唤起 UI
  72. 0.17.0 · 动作入口冲突清理
  73. 0.16.0 · 视觉对齐 opencode(有依据、有取舍)
  74. 0.15.0 · 回滚要「像干净请求」:工具清单变更必须一起消失
  75. 0.13.1 · 修第二轮对抗审查的两个 HIGH
  76. 0.13.0 · 代码风格官方化 + 拆掉「格式被测试冻住」的问题
  77. 0.19.0 · 动作区收敛(审计后修订)

@240xu/dsh-message-ops

DSH web 插件:消息回滚 + 删除 + 恢复。只补 dsh 官方没有的能力。

0.9.0 是破坏性变更:移除了本插件的磁盘分支实现与 Markdown 导出。 两者 dsh 官方都已有(session/fork 与 /api/session.export),详见下方「为什么不重复造轮子」。

0.12.0:消息动作收敛到用户消息(AI 消息的分支入口移除),用户消息动作行新增 opencode 式 Fork from here,面板内新增「编辑并重发」。详见「用法」。

0.13.0:代码风格对齐 dsh 官方(prettier:无分号、单引号、printWidth: 140、 arrowParens: as-needed、trailingComma: all)。同时把 24 处依赖源码排版的 回归锁改成格式无关(按大括号配平抽取 + 空白归一断言)—— 在此之前任何人跑一次 formatter 都会让 18 条测试失败,等于格式被测试冻住了。

10 秒速查

dsh plugin --profile web add @240xu/dsh-message-ops
# 重启 dsh web 后,会话头部点 Message ops
  • 能做什么:回滚到任意消息、只删一条、恢复、从用户消息分叉(opencode before 语义)、编辑并重发;分支调用官方 session/fork
  • 不能做什么:Markdown 导出(请用官方 /api/session.export)
  • 卸载:dsh plugin --profile web remove @240xu/dsh-message-ops

官方没有、本插件提供的

能力官方 dsh 0.2.0-rc.2本插件
消息回滚(遮蔽该条及其后全部内容)❌ 没有。SurfaceOp 只有 append|replace,没有解除遮蔽的操作;undo 仅指草稿编辑器的 Ctrl+Z✅
单条删除❌ 没有。官方 README 明文 "sessions can be archived but never deleted"✅
恢复(撤销一次回滚/删除)❌ 没有✅

为什么不重复造轮子(0.9.0 的取舍)

  • 分支 → 官方有完整实现:RPC session/fork(atSeq 任意)+ SessionStore.fork()
    • UI 消息级按钮 + 官方埋点就叫 branch_session_click。 本插件原先还有一个磁盘实现(applyBranch)作为「官方失败时的回落」,但它语义是错的: 删掉了 isSeeded(官方 fork 设 true)、不写 inheritedEventCount、 也不补 fork closers → 产出的子会话边界可能悬空。已删除,失败时直接提示停止运行中的会话。
  • Markdown 导出 → 官方 /api/session.export 提供 ZIP 归档(原始 JSONL + 附件,面向迁移/复现)。 格式确实不重叠,但本插件的导出零 UI 入口(实测路由可用但前端从不调用),等于死代码。已删除。

用法

  • 会话头部的 Message ops 按钮 → 右侧 OpsPanel 抽屉(消息 / 回收站双 tab); 旧 OpsDialog 仅 window.__MOPS_LEGACY_DIALOG === true 时回退
  • 助手消息 hover 行的 Revert to here / Quote to composer
  • 用户消息 hover 行的 Revert to here / Fork from here(同一动作行,见下)
  • 输入框上方的回撤贴条:展开后可逐条 Restore(消息级恢复入口)
  • 侧栏会话行的 ... 菜单

面板流程:选一条消息 → 回滚 / 仅删除 / 编辑并重发 / 分叉 → 破坏性操作二次确认; 整会话删除在回收站 tab,需输入会话标题确认。

0.12.0 · 动作收敛到用户消息(opencode 对齐)

四个决定,都来自「消息动作到底该挂在哪」这一个问题:

  1. 分叉只属于用户消息。 fork 的目的不是「复制对话」,而是「这句话说错了,拿回 输入框重说」;AI 消息没有可回填的原文,从那里分叉只会得到一个半截上下文。 因此:

    • 助手消息只剩 Revert to here / Quote to composer;
    • 官方 assistant turn-tail 上的 branch 按钮被隐藏(官方无开关,ui-chat 的 Config 只有折叠/性能/链接几项)。识别靠图标几何而非 aria-label 或哈希 class:官方 IconBranchOutline16 的 d 是常量 (packages/client/ui-primitives/src/icons/index.tsx:149),8 种语言的按钮 文案各不相同,class 前缀每次发版都可能变。作用域限定在 [data-turn-tail] 内,并排除我们自己注入的 .mopsUserFork(它故意复用同一 path)。
    • 用户消息的动作行里同时有回滚与分叉,回滚在前(破坏性更高的排前面), 两者都挂在官方 .xzv4MW_actions 里,可见性跟着宿主 hover 走,不做悬浮层。
  2. 分叉是 before 语义,且按「轮」切 —— 不是 seq - 1。 opencode 的 POST /fork 用 before 做排他边界(projector.ts: type === "before" ? lt(seq, ...) : lte(...),issue #35786 也写明 exclusive); dsh 官方 sessions.fork(source, boundary) 的 boundary 是闭区间。 直觉做法是 seq - 1,但实测 40 份语料后否掉了:真人输入的前一个事件恒是 同一 turn 内的 system/message(真实形状 turn/start → agent/inbox/spliced → step/start → system/message → user/message), 按 seq - 1 切会得到一个「turn/step 已开、却没有任何 user 消息」的悬空前缀, 引擎只能补合成 closers,语义也不是「这条之前」。 正确边界 = 该消息所在 turn 的 turn/start 的前一个事件:

    forkBoundaryBefore(seq, turnStartSeq) {
      if (Number.isSafeInteger(turnStartSeq) && turnStartSeq > 0) return turnStartSeq - 1
      return Number.isSafeInteger(seq) && seq > 0 ? seq - 1 : -1   // 老服务端回退
    }
    

    turnStartSeq 由服务端在 GET /messages?view=compact 里给出(src/index.js buildDerived)。注意真人 user/message 事件本身不带 turn 字段—— 归属靠扫一遍事件、用「当前开着的 turn」判定;system/message / assistant/message 才自带 turn/step。没有 turn 信息时退回 seq - 1 (边界可能落在 turn 内部,官方会补 closers,结果仍可加载)。 返回 -1 表示不可分叉(首条消息没有「之前」)。

  3. 原文回到新会话的输入框。 fork 成功后把该条用户消息原文写进 localStorage["dsh.conversation." + childId] = { draft: text },再 uiWorkspace.openSession(childId)。直接写存储而不是 inputActions.setDraft, 是因为 fork 出来的会话还没挂载,此刻 inputActions 仍绑在源会话上;宿主挂载 新会话时会用 readConversationDraft(sessionId) 从同一键初始化编辑器 (dsh-client-ui-conversation:4539-4554,parseStoredDraft 接受字符串形态的 draft)。

  4. 面板里直接「编辑并重发」。 不自己复刻输入框(那会分叉草稿/附件/引用/斜杠 命令/提交状态机一整套行为),而是把原文交回官方编辑器:setDraft(text) → persistDraft()(不落盘会被紧随的 openSession 用旧草稿覆盖,0.11.3 已踩过)→ 关面板。改完直接回车发出,等价于 opencode 的「回撤 → 原文回 composer」,区别是 我们不预先回撤,发不发由用户决定。

安装

零 npm 依赖(只用 node:fs / node:zlib / node:path / node:url;zstd 压缩走 Node 内建 zlib.zstdCompressSync,需 Node ≥ 23.5,Termux 与 Windows 通用)。

# Termux / Linux
dsh plugin --profile web add @240xu/dsh-message-ops

# Windows
dsh plugin --profile web add @240xu/dsh-message-ops
# 或本地目录:
dsh plugin --profile web add file:C:/path/to/dsh-message-ops

重启 DSH web 实例后生效。

安全设计

  • 回滚/删除不重写日志:通过一条承载 surfaceOp: replace 的 system 事件实现, 约束由 DSH 引擎 assertProvenance 校验,违规如实上报(HTTP 409)。
  • 回滚/删除前必须二次确认;运行中的会话一律拒绝(提示先停止)。
  • 分支走官方 sessions.fork(要求先停止运行中的会话),插件不再写磁盘分支。
  • 插件 GET 与宿主 /api 同条件校验 BrowserAuth cookie;有 credentials 服务但 client-connection/browser-session 记录不可读时 401 fail-closed,无该服务的老宿主 保留旧围栏。

端点

方法路径说明
GET/api/message-ops/messages?sessionId=<id>消息级列表(磁盘真相 + 可见性标注 + running 状态);&view=compact 只回 seq/role/snippet/time/visible/shadowed(体积约 −54%)
GET/api/message-ops/search?sessionId=<id>&q=&role=&limit=服务端全文检索(含已回滚消息之外的全部文本),返回 query 对齐片段
GET/api/message-ops/text?sessionId=<id>&seq=单条消息全文(引用按钮按需取)
GET/api/message-ops/session/trash回收站列表(自动清理过期条目)
POST/api/message-ops/revert{sessionId, seq} 回滚到该条(含)
POST/api/message-ops/delete{sessionId, seq} 仅遮蔽该条
POST/api/message-ops/restore{sessionId, seq} 回滚恢复:seq 为某次 revert/delete 标记事件的 seq
POST/api/message-ops/session/delete{sessionId, confirmTitle} 整会话移入回收站(7 天保留;运行中 409)
POST/api/message-ops/session/restore{trashId} 从回收站恢复目录与投影/工作区元数据
POST/api/message-ops/session/purge{trashId} 永久删除(唯一 destructive 操作)

0.11.5 · restore 进度落盘 + turnAligned 可观测

  • 磁盘 restore 漏写 restoredSourceSeqs(数据正确性):buildRestoreNoticeEvent 只写 restoresSeq,而 restoreProgress 靠 restoredSourceSeqs 累计已重放项。 结果每次恢复都被判 legacy(complete:true 但 restoredSeqs 恒空)→ 进度不可见, 且下一轮 excludeSeqs 为空 → 同一区间被反复重放(实测 008f3759 两轮各 209 条)。 磁盘路径改为把 plan.replayable.map(i => i.seq) 落进 notice;discard 时不写。
  • /revert 与 /restore 响应新增 turnAligned:live 路径应为 true, disk/store-miss 路径没有内存 agent,false 属正常。便于运行时确认游标对齐是否生效。

0.11.4 · turn 游标对齐真正生效(会话损坏 P0 续)

  • alignLiveTurnCursor 此前从未执行:插件 export const inject = [] (tools 是可选增强,故意不声明硬依赖),因此 ctx.agents 属性恒为 undefined, 旧实现只读属性 → 恒返回 false。合成 turn 占用编号后宿主 idle agent 的 phase.lastTurn 不推进,下一条真实消息复用同一 turn 号,日志落成 turn/start does not open the expected turn,整份会话不可加载。 改为属性优先、ctx.get("agents") 兜底(与 resolveSession 同取法)。
  • 回归测试:ctx.agents 缺失必须走 ctx.get;游标已领先时不得回退。

0.11.3 · 区间编码、回填落盘与会话损坏修复

  • sourceEventSeqs 区间语义修正(数据正确性):宿主用 encodeSeqRanges 把连续 ≥3 个 seq 无损压缩成 [start, end] 闭区间,读盘时 decodeSeqRanges 再展开。 0.11.2 的 flat(Infinity) 会把 [[3, 6]] 变成 3, 6,丢掉 4、5,写出的 marker 被引擎判 missing 4, 5。改为 expandSourceSeqs() 按闭区间展开并保持首次出现顺序。
  • postamble 幂等(会话损坏 P0):旧实现成功路径与 catch 各写一次 noticeWindowPostamble;postamble/flush 自身抛错时 catch 再写一遍,落成 step/end,turn/end,step/end,turn/end,宿主读盘直接判 step/end does not match an open turn and step,整份会话变 corrupt。 改为 closeWindow() 单点 + closed 标志。
  • composer 回填落盘(「回滚成功但输入框是空的」):宿主 inputActions.setDraft() 只改编辑器、不写 localStorage;紧随其后的 openSession() 会用 readConversationDraft() 从旧草稿重读并覆盖。回填后补 persistDraft()。 对话框入口同理。
  • lastRevertable 排除 shadowed:只判 visible 时,回滚刚落盘、索引未刷新的 窗口里会再次选中同一 seq → 409 surface replace: start seq N not found in surface。 /undo 成功后同时就地标记 seq >= target 行为 shadowed。

0.11.2 · 合成 turn 竞态与历史日志兼容修复

  • 修复回滚在 live 会话中可能产生重复 turn:opsCommit / opsRestore 在取得 live session 后改用 session.log 重算 turn 窗口,不再信任可能已过期的磁盘快照。
  • 合成 turn 成功闭合后对齐宿主 idle agent 的 lastTurn,防止下一条真实消息复用同一 turn。
  • planNoticeWindow 遇到「turn 已开但 step 未开」直接 409,不再嵌套非法合成 turn。
  • applySurfaceReplace / buildMarkerEvent 对 sourceEventSeqs 去重和整数校验(0.11.3 起改为正确展开区间), 区间成员不再丢失。
  • 旧日志中的区间式 sourceEventSeqs([[start,end]])在 0.11.3 已确认是宿主合法编码, 无需维护脚本;tool/result replacement 非 content 字段仍需按需修复。
  • 契约测试在无本地 sessionfix 夹具时自动跳过,不再硬依赖个人绝对路径。

0.11.0 · 方向 A 面板 + 会话回收站

  • src/client.js 新增右侧功能主义抽屉 OpsPanel(560px,≤760px 全屏): 消息/回收站双 tab、200ms 防抖搜索、角色过滤、含已回滚开关、46px 固定行高 虚拟列表、/text 详情、回滚/仅删除/分支、整会话删除(输入标题确认)。 旧 OpsDialog 保留为 window.__MOPS_LEGACY_DIALOG === true 回退。
  • GET /messages?view=compact 为面板默认载荷;GET /search 提供 500 字符 上限的服务端全文检索(LRU 8 corpus 缓存,按 logPath mtime+size 失效)。
  • src/session-trash.js:删除 = 目录 rename + 投影行/工作区/归档/置顶元数据 快照写 manifest.json;恢复拒绝覆盖已存在目录(409);purge 才真正删除。 恢复校验 manifest.sessionId 与 rel(必须是 sessions 根下两层、且与 id 匹配), delete 回滚失败时保留回收站唯一副本,不再无条件删除。
  • 无标题会话取标题只解压首个 zstd header frame(readSessionHeader),损坏的正文 不会阻塞列表;marker 恢复进度改为单次索引扫描。

0.2.4 UI GAP(ux-scout dsh-ui-spec G-M1)

  • S6 违规修复:回滚/删除成功后不再 900ms 裸 location.reload()——改为 devkit 标准 toast(window.__dshDevkit.toast 探测,无 devkit 时降级为对话框 内完成文案)+ done 态新增「刷新页面」按钮由用户手动刷新;与 branch 路径 (refreshList 不刷新页面)行为拉齐。

0.2.3 格式兼容(compat-audit + 用户实测紧急修复)

  • P0 旧单帧 session.jsonl.zstd 读取必崩:readSessionFile 旧实现把帧 0 整段 JSON.parse——旧单帧格式整个文件是一帧、解压出多行 NDJSON,直接抛异常, 与「旧格式兼容」声明矛盾。现改为逐行扫描统一路径:全部帧解压后按行扫描, 首个 type:"session" 行作 header、其余行作事件,v3 多帧 / v4 多帧 / 旧单帧 三种格式一条代码路径(撕裂行仍按完整前缀语义跳过)。
  • P0 v4 会话格式(用户实测 3080 服务暴露):DSH 已升级 v4 持久化 (session.v4.jsonl.zstd,结构同 v3,仅文件名与 version 数字不同)。 findSessionDirs 探测序列改为 v4 → v3 → 旧单帧(此前只找 v3,最新会话 全部 "session log not found");readSessionFile 不做 version 硬校验; applyBranch 分支写回沿用读到的原格式版本(session.v<version>.jsonl.zstd), 不再写死 v3。
  • dedup:listMessages 内联文本提取收敛到 session-file.messageText 单点(ops-core 转出保持导入面兼容),消除双实现漂移面。
  • 回归:40 项测试全绿,含旧单帧/ v4 fixture 往返、listMessages、 findSessionDirs 三格式发现与 v4 分支写回;真实 v4 会话日志已实测通过。

0.2.2 前端收尾(评审 M1/M2)

  • M2 观察范围收窄:侧栏行菜单注入的 MutationObserver 仍观察 document.body(菜单由宿主 React 动态渲染,安装期无稳定锚点),但回调改为 精确过滤——只有新增节点本身是(或包含)[role=menu] 时才调度探测;聊天流 渲染、流式 chunk 等海量 mutation 零探测成本;同帧多次命中合并为一次探测。
  • M1 分批渲染:消息列表先渲 50 条,顶部「显示更多(剩余 N 条)」按钮每次 渐进展开 50 条(上限仍为最近 200 条),避免 200 行单选列表一次性进 DOM; 打开新会话时分页重置。

0.2.1 评审修复(架构评审 arch-review)

  • P0 信任围栏:全部 6 条 /api/message-ops/* 路由接入三层信任判定 (回环 Host 挡 DNS rebinding + sec-fetch-site: cross-site 拒绝 + Origin 同源校验),非回环/跨站请求一律 403。写操作(revert/delete/branch/restore) 另要求 Content-Type: application/json(text/plain 绕预检的洞 → 415)。 为何不引入一次性 CSRF token:浏览器对所有 POST(含 text/plain)都附带 Origin 头,Origin 同源校验已覆盖跨站 POST;自定义头天然触发预检、与 Origin 校验等价——token 只增加握手复杂度而不增加安全性,围栏已足够(论证见 src/ops-core.js 的 isJsonContentType 注释)。
  • P1 写端 replace 拼写前向兼容:applySurfaceReplace 写入时先按当前 运行时 {startSeq,endSeq} 形状写;若引擎报 invalid replace surfaceOp (validateNext 在事件入 log 前抛出,无半写风险)自动降级 {start,end} 重试并记住拼写。当前与未来 dsh cohort 都不炸;读端双拼写兼容已统一收敛到 session-file.readReplaceOp 单点(computeShadowed / planRestore 共用)。
  • P1 大日志让出:新增 readSessionFileAsync,逐帧解压每 8 帧 setImmediate 让出事件循环;messages/export/restore 路由改走该路径。 帧数超过 500 阈值的 export 在 Markdown 末尾追加耗时提示(partial 语义, 内容完整无截断)。
  • P2:readJsonBody 加 1MB 上限(超限 413);findSessionDirs 多 project slug 命中同一 id 时显式报 409 歧义而非静默取第一个;branch 产物 不进会话注册表——需刷新会话列表后才可见(此为宿主扫描行为,见 branch 命令 返回后请刷新列表)。

0.2.0 新增

Agent 工具 message_ops

模型侧可直接调用五操作:action: list | revert | delete | branch | restore | export,参数 sessionId(必填)、seq、upToSeq。工具与 HTTP 路由共用同一套核心逻辑 (src/ops-core.js),行为与错误语义完全一致;失败以文本形式返回,不中断回合。

工具注册是容错的:本插件 inject 保持为空(tools 是可选增强而非硬依赖, cordis 的 inject 是硬依赖声明,缺服务会让整个插件永不加载)。apply 时探测 ctx.get('tools'),有则注册;没有则通过 ctx.inject(['tools'], …) 等它出现; @deepseek-ai/dsh-tools 包不可解析时静默跳过工具,HTTP 面完全不受影响。 因此 @deepseek-ai/dsh-tools 以 peerDependencies 形式声明(^0.1.0-rc.6)。

导出 Markdown

GET /api/message-ops/export?sessionId=<id>&seq=<可选上界(含)>,Content-Disposition 附件下载。user/assistant/system 消息按角色小节展开(## [seq N] role),工具调用折叠为 单行引用(含 80 字符参数摘要);带 parentSession 的分支会话在头部标注来源。

回滚恢复(restore,重放语义)

POST /api/message-ops/restore 传 {sessionId, seq},seq 指向一次 revert/delete 落定的 标记事件(surfaceOp: replace、携带 sourceEventSeqs)。

引擎能力查证结论(见 src/ops-core.js 头部注释):安装的运行时 @deepseek-ai/dsh-session 的 SurfaceOp 只有 append 与 replace 两个变体, 不存在「解除遮蔽」操作——surface replace 是 append-only 的永久遮蔽。因此恢复实现为 重放(replay)而非解除遮蔽:把被遮蔽区间的 user/assistant 消息文本重新 append 为新 事件,文本加 [恢复] 前缀,并先 append 一条 system 说明;tool/call 等不可安全重放的事件 跳过并在结果中计数。原遮蔽区间保持不可见(磁盘日志原样保留)。

语义差异须知:恢复出来的消息 seq 是新的、时间戳是新的、措辞带 [恢复] 标记,且不是 「回到当时」——后续上下文(中间隔着的其他对话)不会被抹掉。若想「干净地回到某点」, 请用 branch 从该 seq 分叉。

兼容性备注:运行时引擎当前 replace 形状为 {op:'replace', startSeq, endSeq}; dsh-src 仓库较新副本已改名为 start/end。本插件写入用 startSeq/endSeq(对齐安装的 运行时),restore 读取端两种拼写都兼容,前向迁移无需改动。

开发

npm test            # 20 个零依赖测试(node --test,Node 26 起目录参数已弃用,改用 glob)
node --check src/*.js

格式兼容性已对真实会话日志验证(多帧 v3 格式;旧单帧 session.jsonl.zstd 读取走同一帧扫描路径)。

License

MIT

0.3.0 · 用户 UI 回撤体系(官方槽集成)

针对 dsh 0.2.0-rc.2 的用户级操作面(agent 工具保留,但 UI 为主推入口):

  • 每条 AI 消息旁的原生回撤按钮:注入官方 conversation.chat.assistant-actions 槽(0.2.0 正式扩展点,条目收持久化 messageId)——「回滚到此 / 删除此条 / 从此分支」直接出现在消息操作行,与官方点赞/点踩并列;0.1.x 无此槽自动 no-op。 messageId→seq 反查经 /api/message-ops/messages(每会话索引缓存)。
  • 分支双路径:0.2.0+ 优先官方 sessions.fork({sessionId, atSeq, increaseTitle}) (子会话自动进宿主列表,成功后可一键 uiWorkspace.openSession 打开); 官方 fork 失败或 0.1.x 回退磁盘级 applyBranch(原路径保留)。
  • 恢复(restore)补完:选中 revert/delete 落定的 replace 标记行时,对话框 提供「恢复」操作(重放语义:新 seq + [恢复] 前缀,不可重放事件计数跳过), 确认弹窗内联语义说明(评审缺口销账)。
  • 官方已有但易混淆的:0.2.0 原生分支按钮(仅限已完成轮次最后一条消息)与 本插件的「任意位置分支/回滚」互补不冲突。

0.3.1 · 槽按钮重构(opencode 式一键 + 去重)

  • 一键立即执行:回滚/删除点击即生效(无对话框),devkit toast 反馈;宿主 0.2.0 chat store 原生响应 surface replace——遮蔽即时呈现,无需刷新页面。
  • 与官方去重:槽内移除分支按钮——官方 0.2.0 已在消息操作行自带分支 (限已完成轮次最后一条);任意位置分支保留在对话框(官方没有的能力)。
  • 原生观感:按钮改用官方 primitives 图标(IconClockOutline/IconTrashOutline, require 失败回退内联 SVG);S3 命中区 ≥44px;S11 双提交防护(busy 互斥)。

0.3.2 · 图标规范化

  • 槽按钮去掉文字 emoji——0.2.0+ 直接用官方 primitives 图标(与原生操作行 1:1 同款); 回退路径的 SVG 也改为官方 IconClockOutline/IconTrashOutline 的精确 path 数据 (1px stroke,与 Regular 变体一致),不再手绘。
  • 字典文案去 emoji(label 仅用于 tooltip/aria,不参与渲染)。

0.4.0 · opencode 式回撤深化(composer 回填 + 回撤 dock)

  • Composer 回填:回滚用户消息后,原文经官方 InputActions.setDraft 自动回填输入框——「编辑重发」零按钮(opencode 招牌交互)。
  • 结构化回撤 dock:注入官方 conversation.input.dock 槽(composer 卡片上方 全宽条目)——「已回撤 N 条(可恢复)」折叠面板,展开列出每个回撤/删除标记, 逐条「恢复」(restoring 中间态禁用,S11);无标记时 dock 不渲染。
  • 数据链:messages 端点为 user 消息新增 fullText 字段(回填原文)。
  • 0.1.x 宿主两个槽均不存在,inject 自动 no-op。

0.4.1 · dock 修复:统计口径 + 原生观感

  • 统计修复:旧版把历史累积的被遮蔽消息全算进「已回撤 N 条」(多次恢复重放后 虚高到数百)。现在 dock 只列手工回撤/删除标记(排除 compaction checkpoint), 每条标记显示其 range 内当前不可见的消息数;头部为标记计数。
  • 原生观感:样式改为官方 GoalDock/TodoDock 同款——36px 高毛玻璃条 (--dsw-specific-menu + --dsw-elevation-panel + --dsw-radius-md)、 composer 卡片对齐宽度(--dsh-composer-dock-inset 系列)、13px/500 label、 28px 圆形切换钮——与目标/计划 dock 视觉完全一致。

0.4.2 · dock 像素级对齐 opencode + 回撤生命周期修正

  • 回撤生命周期(对齐 opencode clear 语义):restore 的 notice 事件现携带 restoresSeq 引用被恢复的标记——dock 只显示活跃标记,已恢复项自动消失 (此前恢复过的标记永远挂在列表里,计数随之虚高)。
  • 像素级对齐 opencode SessionRevertDock(读其 dev 分支源码逐项落地): 42px 头部(reset 图标 + 13px/500 label + 折叠时第一条预览文本 + 180° 旋转 chevron)、rounded-xl + 0.5px 边框 + bg-layer-01 容器、24px 行高 + neutral 小按钮(非文字链)、items 变化自动折叠、Enter/Space 键盘切换、18px sacrificial 空间。

0.4.3 · dock 崩溃修复(Hooks 规则违规)

  • 0.4.1 的自动折叠 useEffect 位于「无标记早退」之后——标记从 0 变非 0 时多出 第 4 个 hook,React 抛出 hooks 顺序错误并卸载整个槽(dock 永远不可见, Playwright 抓到 React #310)。已把该 effect 移到早退之前(无条件 hook)。

0.5.0 · 「按钮没有用」根因修复 + 消息引用

  • 根因:宿主 live 投影只处理 compaction 类 surface replace——插件标记落盘后 打开的会话视图不会收起,所以回撤/删除按钮点了「没反应」(0.3.x 引入的回归)。
  • 修复:回撤成功后经官方 uiWorkspace.openSession(sessionId) 重建会话视图, 遮蔽立即呈现(无需刷新页面);dock 的恢复同样触发视图重建。
  • 消息引用(新):AI 消息操作行新增「引用到输入框」——经官方 InputActions.captureInsertion + insertText 把消息全文以 markdown 引用块 (> 前缀)插入输入框,零 DOM hack。
  • 槽按钮收敛为两个:⏱ 回撤到此 与 ❝ 引用;删除/分支保留在对话框 (官方分支按钮已覆盖最后一条消息场景)。

0.5.1 · 修复 0.5.0 客户端激活失败

  • 0.4.2 的 dock 重写误删了 INPUT_DOCK_SLOT/ID 常量声明——apply() 引用未定义 标识符导致整个客户端激活失败(页面横幅 Failed to load plugins)。 已恢复常量;新增 vm 冒烟自测路径(见 .e2e/)。

0.5.2 · Bug 猎场第一轮修复(P0×1 + P1×3 + P2×6)

  • [P0] activeMarkers 被 0.5.0 误删(与 INPUT_DOCK 同一 commit,0.5.1 只找回常量): dock 100% 静默失效(ReferenceError 被 .catch 吞掉)。已恢复函数 + vm 冒烟守卫。
  • [P1] 工具层未 await async ops(0.2.1 改 async 后漏同步化):list/restore 必抛 TypeError、export 渲出空 text。switch 全部 await + 回归测试(异步桩)。
  • [P1] 引用空块:assistant 消息 fullText 恒 null,回退 '' 通过 null 检查 → 插入空 > 块并报成功。改为按 seq 走新端点 GET /text 取单条全文,空串按不可用处理。
  • [P1] 侧栏菜单卡死:dispatch 缺 sessionId → 对话框永久「正在读取…」。 从 data-row-key 提取 sessionId 一并派发。
  • [P2] seq 索引缓存投毒:失败必须 delete,否则该会话按钮/dock 到刷新前全失效。
  • [P2] 索引口径:同 id 取最早 seq(对齐服务端 buildSeqIndex,不再内联取最大)。
  • [P2] branch 工具渲染:读 keptEvents/parentSession(原读不存在的 kept/parentId)。
  • [P2] dock 标题口径:改报标记 range 内被遮蔽消息数(原报标记数,严重低报)。
  • [P2] busy 卡死:runRevert/restoreRow/对话框 run 全部 Promise.resolve() 包裹—— 同步抛出也落进 finally;restoreRow 补 finally 双保险。
  • [P2] dock 陈旧:新增 dsh-message-ops:changed 广播,回滚/恢复成功后 dock 自动重拉。
  • [P3] 删 en 独有死键 dock.shadowedN;GET 端点补 405 方法校验。

0.5.3 · 「打开即 Running」误报修复(实机 P0 级)

  • isRunning 原查 agents 注册表——会话在视图中打开即有条目 → 打开即报 running → 回撤/删除/分支全部 409 锁死(「按钮没有用」的又一层根因,Playwright 闭环 e2e 实机抓到:curl 未打开时 false、UI 打开后恒 true)。
  • 改读 sessions.list.getSnapshot().byId[].running(host-asserted,与官方侧栏 spinner、官方分支按钮 disabled 同源);老宿主回退 agents 注册表。

0.5.4 · 「点回撤打死整个 dsh」P0 修复(包装实测 exit=1 定位)

  • 根因:dsh 0.2.0 收紧 v4 行准入(assertV4SystemMessageFields)——每个 system/message 的 data 必须带正整数 turn/step。回滚/删除/恢复的通知 事件没带 → 持久层 encodeEventBatch 抛出的 SessionFormatError 没有任何层捕获 → 整个 dsh 进程退出(浏览器端表现为「Failed to fetch」,一切 revert 都静默失败; 此前多次「实例无故死亡」全部是它)。
  • 修复:新增 deriveTurnStep(events)(尾部回溯最近正坐标,兜底 1/1); revert/delete 通知与 restore 通知全部附带坐标;opsCommit 改 async 读日志派生, HTTP/tool 调用点已 await。回归测试断言三处 append 的 data.turn/step 均为正整数。
  • 教训:宿主持久层对未知字段的编码失败会杀进程(记录于 hub——上游应给 encodeEventBatch 加错误边界),插件侧必须严格遵守 v4 行结构。

0.5.5 · v4 准入第 3 关:message.id 非空字符串

  • 0.5.4 补 turn/step 后实机复测仍崩:assertV4SystemMessageFields 继续要求 message.id 为非空 string(string(x, name, nonempty=true))。两处 notice append 的 message.id 现用 randomUUID();回归测试断言三段(turn/step/id)。
  • 行准入全貌(本轮实测三连击):① data.turn/step 正整数 → ② message.id 非空字符串 → ③ role=system + content 数组 + block 文本串。

0.5.6 · dock 死按钮修复:空可重放区间改优雅停用

  • 实机闭环:dock 上标记若遮蔽区间只含 tool/system 事件(或遮蔽了另一个标记), 恢复请求恒 409「no replayable user/assistant messages」——按钮永久死、dock 计数永远清不掉(Playwright 8 连 409 复现)。
  • 改为:planRestore 对空可重放返回空计划;applyRestore 追加停用 notice (含 restoresSeq → dock 移除该行)+ 如实文案「区间无可重放内容」。 非标记/越界/非法 seq 仍按原语义 404/409/400。

0.5.7 · 第四层根因:store-miss 会话的磁盘追加路径

  • 实机链:lazy-view 让会话只在磁盘渲染、不进对象层(视图开着 dock 已渲染, 但 sessions.get(id) 双变体 miss、list() 里只有别的会话)→ 变更操作 404。 官方服务端没有 retain/using 面(那是浏览器侧 ClientSessions 的)。
  • 修复:store-miss 时改为直接向日志追加 zstd 帧(等价持久层 appendLines:encode → open('a') → write+sync+按 size 回滚),事件字段逐项 镜像引擎金标准(根键序 type/seq/time/data/sourceEventSeqs/surfaceOp; restore notice restoresSeq + surfaceOp:"append";重放无 id)。 磁盘会话的视图/搜索/dock 全部按帧重读 → 追加即可见;成功后客户端 openSession 强制重建视图(磁盘追加无 live 投影事件)。
  • live 会话(对象层在册)仍走引擎路径;两条路径共用 turn/step/id 派生与 空区间停用语义。新回归测试断言可见节点/计划语义/字段镜像(44/44)。

0.6.0 · opencode 对齐:回滚长在「我的消息」上 + 贴条化恢复 UI

用户定调「回滚信息怎么可能回滚的是 AI 的信息」——此前唯一一键入口挂在官方 conversation.chat.assistant-actions 槽(只渲染在助手消息上),方向整个反了。 Playwright 实测 opencode web(127.0.0.1:4096)后四项改造:

  1. 用户消息 hover 注入「回滚」按钮(核心):官方无用户消息动作槽 → 受控 DOM 注入,靶点用官方 [data-chat-flow-kind="user"] 属性。点击即回滚 「这条及其之后」(范围语义与 opencode 实测一致,未动)。防错锁:DOM 块 ↔ API 可见 user 行按序配对 + 文本归一化前缀互验,点击时复验一次,对不上拒绝 执行(宁可不回滚,不回滚错消息)。
  2. 恢复条贴合输入框(opencode 实测规格:条→输入框 1px、同宽同列、折叠 42px): .mopsRd 锁 max-width:var(--dsh-composer-card-max-width)(与 uV2eYG_card 同源变量)+ margin-inline:auto + margin-bottom:-5px 把宿主 composerStack 的 6px gap 压成 1px;实测 bar rect [468,751,776,42]、card top 794/前轮 772 → gap=1、dx=-1、dw=2;展开时列表向上涨(底边钉住,1px 不丢)。删 18px spacer hack。EN 文案改 {n} rolled back messages(opencode 语序)。
  3. 对话框删「恢复」模式:恢复唯一入口 = 贴条逐行按钮;对话框只留 回滚/删除/分支(实测 modeCount=3、无恢复项)。顺手防呆:pick 列表排除 role=system(引擎 node0 守卫本就 500 拒绝——不该给用户一个必然失败的选项)。
  4. 恢复去 [恢复] 前缀:重放干净文本(opencode 实测恢复无任何标记); 机制说明保留在 system notice。引擎 append-only 无法真正反遮蔽,重放语义不变。
  • 附带修复:注入器成功/失败回调误用组件级 t()(模块作用域未定义 → ReferenceError 吃掉刷新链:回滚 200 而贴条不出现)→ 改模块级 __t。
  • 验证:单测 44/44;Playwright 全环(发消息 → hover 回滚 200 → 贴条 gap=1 → 展开 → 恢复 → 条清空 + 消息回 + 无前缀)两条路径(注入器 / 对话框)各一遍。
  • 已知观察:宿主 dsh-client-ui-open-in-app 的 event feed 订阅在无 id 重放事件 (引擎原生 compaction 重放同形)上抛 operation.kind 读取错误——客户端已捕获、 功能无损,属宿主脆读,不在本插件范围。

0.7.0 · 用户消息回滚按钮回归「官方动作行」(修布局乱/点不了)

用户实测反馈(0.6.0 的三处真问题,全部根因修复,非症状掩盖):

  1. 按钮压在消息文字上 → 0.6.0 用 position:absolute; top/right 覆盖气泡。 官方用户消息的动作行是 .xzv4MW_actions(height28、消息下方 16px、 hWmORq_actions{margin-top:16px;margin-left:-6px},内含 Copy 按钮)。 0.7.0 把按钮 append 进该行,与 Copy 同级同层(实测 parentCls=xzv4MW_actions、 与 Copy 中心 y 差 ≤6px、与气泡矩形不相交)。
  2. 触屏点不了 → 0.6.0 用 opacity:0 + :hover 显形。改由宿主动作行自身 [data-actions-reveal] 统一控制(按钮作为其子元素天然继承),并加 @media (hover:none) 兜底常显。
  3. 点了没反应 / 有时没按钮 → 两个根因:
    • 锚点不稳:改为从 .xzv4MW_actions 反查最近消息容器(回退 [data-chat-flow-kind="user"]),并用 MutationObserver + 滚动扫描覆盖 虚拟列表动态追加(DOM 里没渲染的块本就无从注入)。
    • 宿主把 <system-reminder> / runtime context 也记为 role=user, 按序配对被这些"假用户消息"带偏(配对错位 / seq 打不上)。已过滤。
    • 打标是异步的:点击时先 await 完成配对再执行(早先点在未打标按钮上静默失败)。
  • 沿用并保持:防错锁(文本互验、对不上拒绝执行)、范围语义(含目标到末尾)、 贴条 gap=1、恢复干净无前缀、运行中 409 守卫(实测点运行中的会话返回 409 "session is running; stop it first",符合设计)。
  • 验证:单测 44/44;Playwright 失败用例先红后绿(inOfficialRow && sameRowAsCopy && !overlapsBubble 全真 + POST /revert 200 + 贴条出现);对话框路径全环 (回滚→贴条→展开→恢复→条清空+无前缀)复跑通过。

0.8.0 · 按轮步进恢复(对齐 opencode 展开行语义)

opencode 实测:展开行 = 每条被遮蔽消息一行,点行只恢复该消息及其同轮回复, 后续保持遮蔽。0.8.0 把"按标记整段恢复"升级为"按轮步进":

  • 服务端:
    • planRestore(events, seq, upToSeq?, excludeSeqs?):upToSeq 截断重放区间; excludeSeqs 排除已重放源 seq(B1 修复:此前每轮从区间头重放 → 消息副本成倍刷屏,实测 seq8 被重放 5 次)。
    • 恢复 notice 新增 restoredSourceSeqs(本次重放的源 seq 列表)—— 引擎准入不拒绝 data 未知字段(研究册 01 §8),且实测磁盘原样持久化。
    • restoreProgress():标记的恢复进度(restored/pending/complete/legacy); pendingRestoreTurns():未重放轮切分(一个 user 及其后继 assistant 为一轮)。
    • GET /messages 为每个标记附 restoreComplete + pendingTurns (含 preview,一次建 seq→text 索引,避免每标记全量扫)。
    • POST /restore 接受可选 upToSeq(live 与磁盘两路径一致)。
  • 客户端:
    • 贴条行 = 待恢复轮(#markerSeq · 首条预览 (+N)),点击只回该轮;
    • 标记活跃判定收敛到服务端权威 restoreComplete(删除客户端 restoresSeq 集合旧判——按轮下部分恢复的标记也带 restoresSeq,旧逻辑会误杀, 实测:第一轮恢复后整条消失);
    • 全部轮恢复完 → 标记不活跃 → 贴条消失;兼容旧 notice(视为整段已恢复)。
  • 验证:单测 49/49(新增 5 条:upToSeq 截断 / notice 字段 / 进度推进 / 旧通知兼容 / B1 排除);Playwright v080.cjs 全环 GREEN: 5 行=5 轮 → 点第一行(S1 副本=1、余 4 轮、贴条保持)→ 逐轮清空 → markerActive=false、无前缀、贴条消失;0.7.0 布局用例复跑 GREEN; 对话框路径复跑通过(恢复亦按轮)。

0.8.1 · discard 停用面 + 用户会话实查结论

  • 新增 POST /restore {discard:true}:不重放内容、仅停用标记(notice 带 discarded:true,进度判定视为完成)。适用场景:清掉"没有可重放内容/不希望 重放"的惰性标记。
  • 用户主会话(4e10c1a2)实查:8 个测试标记在 0.8.0 语义下全部 complete=True (区间无可重放消息内容——链式遮蔽的是其他标记),贴条不再显示,无需丢弃; hidden≈2710 的大头是 compact-checkpoint(引擎自身历史压缩设计),非测试污染 (修正 0.8.0 时代"2709 条测试污染"的误判)。清理前已备份日志 (~/.dsh/backups/session-4e10c1a2.*.zstd.bak,9.2MB)。
  • 单测 50/50(+1 discard 语义)。

0.9.0 变更清单

移除(与官方重复或无入口)

  • src/branch.js 整个删除;/api/message-ops/branch 路由、tool 的 branch 动作、 客户端的磁盘回落(diskBranch)与 fork.disk 文案全部移除。分支现在只走官方 sessions.fork(), 失败时提示「官方分支不可用,通常是会话正在运行」。
  • /api/message-ops/export 路由、exportMarkdown()、tool 的 export 动作移除。 导出一律用官方 /api/session.export(ZIP 归档)。
  • 死代码清理:IconTrash/TrashFallback(零渲染点)、对话框内永不可达的 restore 分支、 4 组 restore locale 文案(dock 用自己的 dock.* 键)。

修复(均为浏览器实测发现,静态审查无法发现)

  1. 用户消息回滚按钮恒定失效。四个 bug 叠在一起:
    • userAnchors() 匹配 CSS-module 生成的前缀 Sixlwa_*,而 dsh 0.2.0-rc.2 的哈希已变为 EvIC1a_* → 锚点集为空;
    • 过滤了 m.visible !== false,但宿主聊天视图不施加遮蔽模型、照常渲染被回滚的消息 → 配对候选与 DOM 块零交集;
    • injected() 的 current\s+runtime\s+context 跑在 norm()(replace(/\s+/g,''),删掉全部空白) 之后的串上 → 永远匹配不上,伪 user 行漏进配对;
    • 按钮可用性在 fetch 回调里做事后全局扫描,与 DOM 注入/重排竞态 → 块已打上 seq 而按钮仍禁用。 现在实测 data-mops-seq=["8","54"]、按钮可用;配不上的块会被禁用并说明原因, 而不是留一个点了才报错的装饰按钮。
  2. 对话区硬编码 _flowItem 会误收官方 turn-tail(官方 branch 按钮所在行)→ 收紧为 data-chat-flow-kind="user" + _userRow|_userStack|_bubble。
  3. defineTool 注册失败原先 catch(() => {}) 静默吞掉,无法诊断 → 改为 console.warn。

契约测试

新增 test/revert-contract.test.js:把 dsh 官方三层闸接进测试 —— ① 格式层 assertV4RowAdmission + restoreReleasedV4Artifact、 ② 运行时层(role 匹配 / source.kind 非空 / system/message 必须 system-prompt)、 ③ UI 装配层(turn/end.data.reason 必填)。 只��第 ① 层会得到「全绿但 UI 崩」的假象(0.8.x 实机踩过:dsh-client-ui-trajectory 的装配器无防护读 reason.kind,缺字段直接白屏)。

事件形状的三条硬规则(写错就是整份日志报废)

  1. system/message 的 source.kind 必须严格是 "system-prompt" —— Session 运行时层(dsh-session/lib/index.js:1206)比格式闸更严。
  2. turn/end.data.reason 必填(types.d.ts:271-276)——UI 装配器直接读 reason.kind。
  3. 空闲会话要写 notice,必须自带一个合成 turn(turn/start+step/start+notice+step/end+turn/end, 带 reason:{kind:"interrupted"})——v4 要求 system/message 匹配打开的 turn/step, 而正常会话都以 turn/end 收尾(实测 340/364 空闲时是关闭的)。引擎 append-only 插不进已有 step, 但合成一整个 turn 合法。dsh 自己的崩溃恢复(openTurnClosers)用同一手法。

surfaceOp: replace 的 notice 不会渲染成对话行(dsh-client-ui-chat/lib/client.js:9370 只渲染 surfaceOp==="append"),这是设计如此 —— 回撤提示由输入框上方的贴条负责。


0.9.1 / 0.9.2 · 回滚「真的生效了」

问题:回滚对用户是无效的(0.9.0 及以前)

浏览器实测发现:点回滚后 dock 计数变了,但消息在界面上���行没变。

根因(源码 + 实测双重证实):dsh 刻意把「模型可见」和「人类 transcript」分成两套。 surface.d.ts:51-63 明写:

Append-origin events are that transcript's durable source material; replacement copies stay model-only.

即 surfaceOp: {op:'replace'} 只折叠模型 surface(实测 393→13 节点,模型侧确实生效), 但前端 transcript 不施加遮蔽,被遮蔽消息照常渲染且完全可见 —— 连我们的 replace marker 自身都不渲染(systemMessageDefinition.buildViewNode 对 surfaceOp !== 'append' 直接 return null,dsh-client-ui-chat/lib/client.js:9370)。

所以之前那句「回滚已完成」是误判:只验了日志写入与 dock 计数,没验用户眼���。

解法:渲染层按 node-key 精确映射

每一行渲染节点带 data-chat-node-key,形态 <len>:<kind><id>,而 <id> 就是日志事件的字段:

行 kind<id>查表
user / steeringmessageIdbyMessageId
assistant-step<turn>:<step>byTurnStep
tool-callcallIdbyCallId
turn-tail / turn-process / turn-errorturn 号byTurn ← turn/end 的 seq
developer-messagemessageIdbyMessageId

配合服务端的 revertFences / turnEndSeq,客户端做精确查表 + fence 比较, 命中则 row.setAttribute('hidden', 'until-found')。

hidden="until-found" 与宿主 useSearchableHidden 同款:Ctrl+F 仍能搜到, 且宿主的分页锚点选择器 [data-chat-paging-anchor]:not(:empty):not([hidden]) 会自动跳过,滚动恢复不受影响。

三个踩过的坑(都有回归测试锁住)

  1. 不能用 visible 当隐藏判据。visible 是「是否在模型 surface 上」,而 tool/call / turn/* / model/* 根本不是 surface 节点,它们永远 visible=false —— 照抄会把所有工具调用行永久藏掉(实测 3/3 全被误藏)。 正确口径是 reverted(= opencode 的「边界及其后」:messages.slice(0, boundaryIndex))。
  2. 回合页脚不是消息,锚到 turn/end 的 seq,永远不在 reverted 集合里, 所以判据必须直接对 fence 数组比较。 且要用 turn/end 而非 turn/start —— 回撤边界常落在某个 turn 内部。
  3. 旧的 40 字符前缀包含匹配实测 4 条错 2 条(「继续」命中「继续,…」), 已整体删除,改为 node-key 查表。

实测结果

回滚后: hidden 5(user + turn-process×2 + turn-tail×2)
        被回滚消息 disappears from DOM ✅
        该轮之后的助手回答也消失 ✅
        更早的消息完好 ✅
恢复后: hidden 0,dock 消失,消息全部回来 ✅
查表命中率: unresolved 0 / mismatch 0 / hiddenButNotReverted 0 ✅

已知限制

  • restore 是「重放」语义(dsh 无 unshadow 原语,见 README 上文的机制说明), 所以「恢复后再回滚」的复合场景下,重放副本的行可能不被识别为 user 行。
  • restore 后界面需要触发刷新才更新(已接 __refreshSurface 钩子 + MutationObserver, 但极少数情况下仍需重载页面)。

0.9.3 · /undo —— 复刻 opencode 的招牌交互

「我说的那句话不对」→ 打 /undo → 消息从 transcript 消失 + 原文回填输入框。

走官方斜杠管线(ctx.inputTriggers.registerSource),不自己劫持键盘, 所以菜单分组、搜索、Enter 裁决、composer 状态机全由宿主负责。

ctx.inject(['inputTriggers'], (sub) => {
  doRegisterUndoSlash(ctx, sub.inputTriggers || sub)   // ⚠️ 见下方坑 2
})

注册路径踩过的四个坑

  1. candidates 必须返回 Promise。宿主在 InputTriggerController.fetchCandidates 里直接 source.candidates(...).then(...),同步返回数���会抛 source.candidates(...).then is not a function,整个菜单静默失效。
  2. cordis inject 回调收到的是「注入命名空间」,服务在其同名字段下 (与本文件 sub.uiWorkspace || sub 同模式)。把 sub 直接当服务用会抛 cannot get property "registerSource" without inject。
  3. rowsCache 是注入器闭包私有的(installUserRevertInjector() 内)。 /undo 的 onPick 在模块级作用域,直接引用会 ReferenceError, 表现为「点菜单毫无反应」。需要 __peekRowsCache() 出口。
  4. __currentSessionId 来自 DOM dataset,可能带 session- 前缀, 而 API 只要裸 uuid → 请求静默失败。

目标必须挑 surface 上的那条

restore 是「重放」语义 → 原件被遮蔽、副本带新 seq 落在日志尾部。 无脑取最后一条会挑到副本,而副本不在模型 surface 上,服务端以 surface replace: start seq N not found in surface 拒绝(实测)。 回滚的前提就是「目标在 surface 上」,所以先筛 visible。

实测

composer 输入 /und → 官方菜单出现「回滚上一条消息 undo 移除最后一条用户消息,原文回填输入框」
点击 → 目标 = seq 8(surface 上的那条,不是重放副本)
      → 转��成功,dock 计数 13
      → 原文「分配几个子代理把模型广场的模型价格什么的做好…」回填进 composer ✅
      → transcript:24 行中 22 行 hidden,被回滚消息从正文消失 ✅

0.9.4 · 修「正文几乎空白」

症状

用户截图里 133 轮的会话正文几乎全空,只露出零星几个「工具已更新」标记。

根因

19 个 marker 是离散区间([9,9]、[10,4368]、[6830,8573] …), 服务端把它们塌缩成 revertFences = range.start 列表 = [10, 6830, 9829, 11429], 客户端判据 seq >= fence(任一命中)等价于 seq >= min(fences) = seq >= 10。

于是 4792/4793 条被判为已回滚 → 全部 hidden → 正文空白。

量化验证(两条真实会话)

判据隐藏数与宿主 visibleCount 吻合?
seq >= min(fence)(事故判据)4792 / 4793✗ 只剩 1 条可见
未恢复 marker 区间并集2707部分吻合
!visible(模型 surface)2710 / 4574✓ 2083 / 464,精确吻合

顺带纠正一处旧断言:tool/call 并非永远不在 surface 上 —— 实测 957 条 tool/call 是 visible 的,visible 对它同样有意义。

修复

  • 消息类行:hide = !visible.has(seq)(直接问模型 surface)
  • 回合 chrome(turn-tail / turn-process / turn-error / model-retry): hide = !visibleTurns.has(turn) —— 该 turn 还有可见消息就露出, 因为 turn/end 本身多半不在 surface 上
  • 删除 revertFences / m.reverted 两个编码了错误判据的字段(已无消费者)

实测

修复前:  正文几乎空白(截图)
修复后:  113 行 → 可见 67 / 隐藏 46,可见字符 36,851

历史事故记录(从源码迁移)

一次性事故复盘 / 已过时的实现说明,从 src/*.js 搬到这里。行号 = 基线 cff530a(0.10.0)的原行号;源码处只留一行指针。

未搬(仍是当前约束,留在源码):ops-core.js 信任围栏三层论证、buildReplayEvent 的 turnStep 教训、diskSurfaceNodes vs diskVisibleNodes、replaceShape 写端拼写探测;index.js 的 turnEndSeq 定位注释(解释紧邻代码为何锚 turn/end 而非 turn/start,README「三个踩过的坑 #2」只有结论摘要);client.js 的 inputTriggers.registerSource PickOutcome 契约、afterWrite(rawSid) 必须带 session- 前缀、applySurfaceVisibility 的 seq == null ⇒ 移除 hidden、不要把 _flowItem 当兜底、injected 正则「id 后必须跟得上具体形态 / 没证据就不猜」、setDraft 与藏行红线;mcp-skill-hub 的 exports / return module.exports。

#1 src/client.js:2-14 · 客户端模块头(含已过时的「整页刷新」)

dsh-message-ops 客户端:会话头部按钮 + 共享操作对话框 + 侧栏行菜单项。

一个对话框承载三种操作(回滚 / 删除 / 分支):
  - 拉取 GET /api/message-ops/messages 展示消息级列表(可见性已标注)
  - 回滚/删除为破坏性操作:需勾选风险确认(回滚仍可通过再次发送恢复,
    删除为遮蔽语义;日志永远 append-only,原事件保留)
  - 分支为唯一非破坏操作:无需确认,成功后提示新会话 id 并刷新列表
回滚/删除成功后整页刷新(surface 变化需要会话重渲染)。

结构照抄 @huanlin/dsh-plugin-session-delete 的成熟模式:slots.inject
延迟注册(宿主晚声明也不白屏)、locale 服务 zh/en、DOM 注入侧栏菜单。

⚠️ 「整页刷新」这句已经过时:0.5.7 起成功路径是 notifyDone + emitChanged() + openSession() + 150/700/1600ms 三连 __refreshSurface,不再 location.reload。

#2 src/client.js:1024-1025 · 0.6.0「absolute 覆盖压字」教训

0.6.0 的教训(用户实测反馈):absolute 覆盖在气泡上 = 压住文字、布局乱;
且 opacity:0 + :hover 在触屏上永远不显形(点不了)。

→ 0.7.0 的解法是把按钮 append 进官方动作行 .xzv4MW_actions,可见性继承宿主。

#3 src/client.js:1074-1097 · injected 正则两轮事故 + 120 份日志普查

0.9.0 两个实机 bug(浏览器实测:4 个按钮 data-mops-seq 全为 null,点击必失败):

① 正则跑在 norm() 之后的串上,而 norm 是 replace(/\s+/g,'') —— 删掉**全部**空白,
   `current\s+runtime\s+context` 永远匹配不上 → runtime-context 伪行漏进配对。
   修法:injected() 改判**原始 snippet**。
② 过滤了 `m.visible !== false`,但宿主聊天视图**不施加**遮蔽模型,照常渲染
   被回滚遮蔽的消息 → 配对候选与 DOM 块零交集。
   修法:DOM 配对必须覆盖全部 user 行(含遮蔽态)。
   注意:对话框的「可回滚目标」列表仍应过滤 visible(那是另一套语义)。

0.9.6 通知行污染:

原先只排 system-reminder / runtime context 两种,但宿主把 `background job …`、
`Agent … sent a message:`、`Background subagent … finished`、`[model changed: …]`
也记成 role=user,于是 /undo 会把**通知行**当目标 —— 回滚范围与回填文本都变成
通知内容(378 会话普查:38 个 10% 命中;实测 2188f4ac 目标是
"Background subagent f09a18f6… finished…",而用户那句在 seq829)。
稳妥做法本该在服务端打 injected 标志,先在客户端补齐正则。

120 份日志普查样本(正则按真实样本收紧,不靠猜):

 256 × <system-reminder>…        128 × background job bash-358 (…
  38 × [model changed: …         26 × Background subagent <uuid>
  21 × Agent <uuid> sent a message:

#4 src/client.js:1250-1256 · 隐藏判据「revertFences → visible → shadowed」演进史

演进:revertFences 塌缩(离散区间→min,全藏)→ visible(模型 surface,
live 路径下 tool/call 全藏)→ 现在的 shadowed(恒定、与会话活跃无关)。

0.9.4 判据(当时):**模型 surface**。此前用 revertFences 塌缩成
`seq >= min(fences)`,在一个有 19 个**离散** marker 的会话里
min fence=10 → seq>=10 全藏 → 4792/4793 全被隐藏 → 正文几乎空白
(截图复现)。改用 visible 后与宿主 visibleCount 精确吻合。

结论(留在源码的一行):0.9.6 起藏行判据 = shadowed(replace 区间)。

#5 src/client.js:1753-1755 · 「5 个入口只有 2 个回填」

0.9.6 统一出口 —— 此前 5 个回滚/恢复入口里只有 2 个回填
(对话框 :403 与 /undo :1495),悬停回滚按钮、AI 时钟按钮、dock Restore、
/redo **全部不回填**,表现正是「回滚成功了但消息框是空的」。

已在本 README 其它章节(源码同步压缩成指针,未新增重复内容)

源码位置(原行号)已有章节
src/client.js:404-408 分支磁盘回落语义错误「为什么不重复造轮子(0.9.0 的取舍)」
src/client.js:1116-1131 node-key 映射背景长文(surface.d.ts:51-63、40 字符前缀事故)「0.9.1 / 0.9.2 · 回滚「真的生效了」」
src/index.js:1-23 模块头里的 0.9.0 分支/导出移除段「为什么不重复造轮子(0.9.0 的取舍)」
src/index.js:336-340 revertFences 塌缩复盘「0.9.4 · 修「正文几乎空白」」
src/ops-core.js:551-557 0.5.7 磁盘路径复盘「0.5.7 · 第四层根因:store-miss 会话的磁盘追加路径」

0.18.0 · 行内操作三项修复 + 面板唤起 UI

用户反馈(原话):

「编辑重发的按钮可以和回撤放在同一个地方,就是用一个笔的那个图标」 「引用AI的话…我怎么还得点一下这个类似于复制的按钮,然后这个会话直接跳进输入框, 这UI做的怎么这么诡异?而且这样真的行吗?查点权威资料和官方代码」 「还有就是那个时间的图标,怎么点那个也是回撤啊?」 「消息操作的那个面板的,唤起它的UI显示明显有问题」

1 · 行内编辑按钮(铅笔图标)

新增 ensureEditBtn,与回滚同一动作行,顺序 回滚 → 编辑 → 分叉 (破坏性从左到右递减)。图标用官方 edit-outline 几何。

回填复用 refillComposer —— 0.17.0 刚清理掉「同语义两套实现」, 这里绝不能另起一条。

2 · 引用改用官方 insertQuote

旧实现是错的(用户直觉对:「这样真的行吗」)。查宿主源码:

// dsh-client-ui-conversation:15369
insertQuote: (text) => this.insertQuote(text)
// → draftEditor.insertQuote(text, labels)
// → $replaceDetectSpanWithNodes({start, end}, [$createQuoteNode(text, labels), Go(" ")])

官方插入的是结构化 QuoteNode(带删除标签、独立 undo 编辑、自动补空格分隔)。

而 0.5.0 的旧实现是:

text.split('\n').map(l => '> ' + l).join('\n') + '\n\n'   // ← 手工拼 markdown
inputActions.insertText(quoted, span)                        // ← 裸插文本

三个问题:

  1. 不是引用 —— 宿主不会渲染成引用块,也没有删除按钮
  2. captureInsertion() 返回的是插入位置快照,用户在菜单里点完到文本到达之间 若改过草稿,位置就失效 → 文本插到奇怪的地方
  3. 手工拼的 > 会和已有内容粘连

已改为直接调 inputActions.insertQuote(text)。

3 · 时间文本与回滚按钮的误触

用户说「点那个时间图标也是回撤」。核实后不是绑定错误 (插件代码里没有任何 data-clock / timeStart 的事件绑定), 而是视觉与命中区相邻:

宿主 DOM: div.actions[data-clock="start"] > span.timeStart + button(copy) + extraActions
                               ↑ 时间文本 padding-right:12px   ↑ 8px gap
我们: appendChild → [回滚][编辑][分叉] 紧跟在复制按钮后面

触屏上 28px 按钮的命中区紧贴时间文本,点时间很容易滑到回滚上。

修法:给插件按钮组加视觉分隔(首按钮左侧 10px 间距 + 1px 竖线), 把「消息元信息(时间/复制)」与「破坏性操作」在视觉和命中上分成两组。

4 · 面板唤起 UI

唤起按钮用的是分叉图标 —— 一个「消息操作」面板长着「分叉」的脸, 用户会以为点它是 fork。这就是「唤起它的 UI 显示明显有问题」。

改为官方 list-pen-outline(列表 + 笔)—— 同时表达「浏览消息列表」与「编辑」, 正是这个面板的用途(用户确认它可以当作「编辑 + AI 对话上下文面板」)。

圆角从写死的 6 改为 4,与行内按钮统一。

主题自适应审计

逐行检查面板 CSS:没有裸 hex,所有颜色都是 var(--dsw-alias-*, <浅色 fallback>) 形式 → 深色模式自动跟随宿主 token。 新增测试锁定这条(防止以后有人图省事写死颜色)。

新增 test/row-actions-v2.test.js(11 条)。测试 309(305 pass / 4 skip)。

0.17.0 · 动作入口冲突清理

用户问:「那个编辑并重发是你的吗?你看看有多少是功能冲突的」

是插件自造的(git log -S "panel.editDone" 只有 d699ddb 一次提交), opencode 没有消息级编辑。查下来三个真冲突,全部已修:

C1 · 回填有两条实现,可靠性不同

路径旧实现问题
refillComposer(回滚)writeSessionDraft(0.14.0 起)不依赖编辑器,可靠
startEdit(面板编辑)__inputActions.setDraft + persistDraft,且 !__inputActions 直接失败依赖 bindDraftPersistence 绑定的 writer,视图未挂载时空转

同一语义两条实现、两种可靠性 → 合并为同一条路径: 先 writeSessionDraft 落盘(权威值)→ 尽力 setDraft(立刻可见)→ 回读校验。

R2 · 「编辑并重发」文案误导

startEdit 完全不调服务端(函数体内无 fetch)。它只把原文放回输入框, 不遮蔽原消息。用户改完发出后:

  • 日志 append 一条新 user/message
  • 原消息 仍然 visible,不被遮蔽
  • 界面上出现两条相似消息,用户以为「改了」,实际「加了一条」

而旧文案 编辑并重发 / Edit and resend 暗示的是「修改原消息」。 改为 复制到输入框 / Copy to composer,提示补上 「发出后会新增一条消息,不会修改原消息」。

C2 · 防 409 护栏只有一条路径有

/undo(runUndo)在回滚成功后立刻把 rowsCache 里 >= seq 的行标成 shadowed = true,注释写明是为了防止刷新窗口内重复点击拿到已遮蔽的 seq。 按钮路径 runUserRevert 漏了这段 —— 同一语义两套实现,其中一套少了护栏。

抽成共享函数 markRowsShadowedFrom(boundarySeq),两条路径都调。

没动的重复入口(故意的)

/undo vs 按钮、/redo vs dock 恢复、message_ops tool vs UI—— 这些是不同上下文/不同调用方,共用 ops-core.js 同一个核,不算冲突。 详见 design-demos/conflict-audit-lead.md。

新增 test/action-conflict.test.js(6 条)。测试 298(294 pass / 4 skip)。

0.16.0 · 视觉对齐 opencode(有依据、有取舍)

方法:不是照着印象改,而是拉了 sst/opencode 源码 (commit 82229fb7,version 2.2.5,sparse-checkout 取 packages/app/src + packages/session-ui + packages/client/src),逐条读 CSS 与组件,产出 design-demos/opencode-visual-spec.md(912 行,64 处 证据: 标注)。

三个反直觉的实证结论(都做了反向验证):

  1. opencode 没有「悬停浮出一排按钮」 —— 只有 1 枚 chevron-down 按钮 (20×20,radius 4px,图标 14px,ghost-muted #808080),点击弹出下拉菜单, copy / fork / revert 全在菜单项里。
  2. revert 后没有任何「变灰 / 删除线 / 半透明」 —— 是投影层直接过滤 (message.id < revertMessageID),被裁的行根本不进虚拟列表。 全仓 data-reverted / isReverted 零命中。
  3. 没有消息级 edit,也没有「已回滚」常驻提示 —— 只有 5s toast 带 Undo
    • /undo /redo 斜杠命令。

本次改动(按用户拍板:保留横排按钮与 dock,只对齐视觉规格):

项改前改后依据
图标尺寸15px14pxopencode size="small"
按钮圆角6px(宿主默认)4pxicon-button.css:29-33
图标颜色label-tertiary(#9a9aa0)#808080--v2-icon-icon-muted
hover 前景不变色转 label-secondaryicon-button.css:103-106
焦点可见无focus-visible 2px outline无障碍补齐
淡入动画宿主 80ms保持 80ms(不改)opencode 源码注释明写 "without a fade";宿主 80ms 已足够短

明确不改的(宿主约束,照抄反而更差):

  • 20px 按钮尺寸做不到:DSH 宿主动作行 .xzv4MW_actions 已定 height: 28px,插件内嵌按钮改 20px 会撑裂行高。只能对齐图标与颜色。
  • 不改成 chevron 菜单:用户明确要「fork 与回撤在同一位置」,改成菜单会推翻 这个诉求;且 DSH 无现成菜单浮层体系,要新建 shell.overlay 实现。
  • 保留 RevertDock 与确认弹窗:opencode 的 revert 是轻量 stage+redo, DSH 是 append-only 遮蔽 + 需刷新生效,代价高得多;且 DSH 没有 /redo 那种便捷恢复入口,删了 dock 会让用户不知道如何恢复。
  • 不引入 --v2-* token:插件全部使用 --dsw-alias-*,深色模式自动跟随。 引入第二套 token 会出现双轨配色。

0.15.0 · 回滚要「像干净请求」:工具清单变更必须一起消失

用户原话:「我说的撤回,只是不显示我重新发起的请求,就像干净请求。」

现象:回滚一条用户消息后,主对话流里「新增了 N 个工具」那行还在; 供应商报错(llm/retry)相关的重试行也留着。

根因(全部在宿主实际下发的 bundle 里取证,不是推测):

// 宿主 dsh-client-ui-chat:developerMessageDefinition
match: (event) => event.type === "developer/message" ? { ... } : null

对比 user/message 的 match:

if (event.type === "user/message")
  return isAppendSurfaceEvent(event) && !isCompactionCheckpoint(event) ? ... : null

user/message 有 isAppendSurfaceEvent 这道闸,developer/message 没有。 再加上 isVisibleChatNode 里对含 tool-addition/tool-removal 的 context 节点 强制可见:

node.kind !== "context" || node.data.content.some(b =>
  b.type === "tool-addition" || b.type === "tool-removal")

结果:surface replace 把工具清单变更事件正确遮蔽了(模型上下文是干净的), 但 UI 照样渲染那一行 —— 用户看到「回滚没撤回干净」。

修法(插件侧唯一抓手是渲染层遮蔽同步,宿主渲染器改不了):

  1. listMessages 收录 developer/message(此前只收 user/assistant/system)。 不收 → 遮蔽状态传不到客户端 → byMessageId 查不到 → 调用方「保持可见」。
  2. textSegments 把 tool-addition/tool-removal 渲染成 +name/-name。 不转 → snippet 为空串 → 被 rows 的 r.sn 过滤条件当无效行丢掉。
  3. compact 投影补 id。客户端用 <kindLen>:<kind><id>(实测 17:developer-message<uuid>)拼 data-chat-node-key 反查 seq, 没有 id 这条链就断了。

实机验证(3096,真实会话 1530 行):

seq= 458 id=2a306c76-6 shadowed=true | +sidebar_open
seq= 471 id=deb0ed06-1 shadowed=true | -subagent_codex
seq= 552 id=b32cdc8b-8 shadowed=true | +subagent_codex
seq= 700 id=8ea41701-f shadowed=true | +import_chat
seq=3726 id=61b519dd-d shadowed=true | +browser_click

没有做的(且不应该做):llm/retry / assistant/attempt 不是 surface 节点, 它们不进模型上下文,遮蔽它们没有语义意义。如果你在界面上看到重试痕迹, 那是宿主自己的 model-retry / turn-error 节点,不在本插件管辖范围。

新增 test/developer-message-shadow.test.js(6 条)。测试 292(288 pass / 4 skip)。

0.13.1 · 修第二轮对抗审查的两个 HIGH

HIGH-1|隐藏官方 branch 按钮在 0.2.2-alpha.2 上 100% 无效。 0.12.0 用 svg path 的 d 前缀 M13.0762… 识别官方 branch 图标 —— 但该版本图标已改为 sprite 复用,官方组件(app bundle 里的工厂 T(e,t))渲染的是:

<svg data-icon="dsw-icon-branch-outline-regular" aria-hidden="true">
  <use href="#dsw-icon-branch-outline-regular"/>
</svg>

DOM 里根本没有 <path>,M13.0762 在整个已安装 dsh 里 0 次命中 → d 恒为空 → suppressHostBranch() 一个按钮也藏不掉,且静默失效、无报错。 更糟的是 test/fork-dom-behavior.test.js 当时用自造的 <path d="M13.0762…"> 夹具, 测的是自己的假设,不是宿主现实,所以一直绿。 修法:主判据改为 svg[data-icon="dsw-icon-branch-outline-regular"](id 由工厂 Qv(e,t)=dsw-icon-${name}-${size}`` 生成,属契约级常量),保留内联 path 作为旧宿主兼容回退; 测试夹具换成真实的 <use> 形状。

HIGH-2|审查者认为面板分叉恒走坏回退 —— 复核后不成立。 报告称 OpsDialog 没带 &view=compact 所以 turnStartSeq 恒 undefined。 实测(_internals.opsList 对同一会话跑 full / compact 两遍):full view 同样返回 turnStartSeq = 4,因为 buildDerived 是把它挂在消息对象本身上,而非 compact 分支返回的就是这些完整消息对象 —— compact 只是挑了字段子集,不是唯一来源。 故未改动。

其余三条 MED 的复核结论:

  • seq - 1 回退对 inheritedEventCount 会话不成立 → 不成立。实测 9 个 isSeeded 子会话,seq 均从 0 重新编号(minSeq=0),inheritedEventCount 只是日志 长度统计,不偏移 seq;restore 副本同理(服务端按新 seq 重算 turnStartSeq)。
  • persistDraft 空转却报成功 → 成立,已修:写入后回读 dsh.conversation.<sid>, 对不上就报 panel.editUnavailable 且不 close。
  • 160 字截断的 snippet 冒充原文 → 成立,已修:新增显式 detailFull 状态, /text 失败即降级为 false,编辑按钮要求 detailFull === true。

两条 LOW 均成立并已修:revert.unresolved 从未定义(改用已有的 slot.revertUnmatched);OpsDialog 的 op.branch 文案仍是旧「含该条」语义(改为 before)。

新增 test/reviewer-round2.test.js(8 条),其中 i18n 锁会扫出任何未定义的 __t('…') key。测试 276(272 pass / 4 skip)。

0.13.0 · 代码风格官方化 + 拆掉「格式被测试冻住」的问题

风格(与 ~/.dsh/deepseek-harness-master/.oxlintrc.json 的 @stylistic/* 规则一致):

规则值
seminever(无行尾分号)
quotessingle(avoidEscape)
printWidth140
tabWidth2
trailingCommaall
arrowParensas-needed(单参数去括号)

npx prettier --check "src/*.js" 现在全绿;npm test 267(263 pass / 4 skip)。

为什么这件事以前做不了:仓库里有 24 处「回归锁」直接对源码原文做正则,而且用 '\n }' 这类固定缩进当函数结尾标记。它们防的是真 bug(藏行不更新、会话损坏、 双提交防护…),但实现方式让任何格式化都会全线变红 —— 于是风格永远不敢动。 0.13.0 把它们改造成格式无关的两种原语:

// test/_source.js —— 与缩进/换行/引号风格无关
flat(text)          // 空白归一:`a\n  b` → `a b`
blockAt(text, needle) // 从 needle 起按**大括号配平**切出整块

改造后每条锁断言的仍是同一件事(例如「applySurfaceVisibility 必须在门控 if 之外无条件执行」),只是不再依赖排版。新增 lastFnBlock 处理「先 var x = null 后 x = … 赋值」的同名情况。

顺带修的:test/user-fork-edit.test.js 等新测试从一开始就写成格式无关 (大括号配平 + 真执行),不再重蹈覆辙。

0.19.0 · 动作区收敛(审计后修订)

用户:「编辑并重发为什么还存在?把情况理清楚,逐项解决」+ 启用 superpowers。

审计发现决定性事实:profile 里已安装并加载 @linxin666/[email protected],它注入 conversation.chat.turnTail 槽,提供的正是「编辑并重发」:回合末尾一个文字按钮 → 原地 textarea(radius 12px, 全 --dsw-alias-* token)→ sessions.fork({atSeq: seq-1}) → reference.binding.session.prompt(editedText, "queue")。源会话不动,Esc 取消、 Ctrl/Cmd+Enter 重发。也就是说本插件从 0.12.0 起造的编辑入口全是重复的劣质轮子。

设计文档:docs/superpowers/specs/2026-11-06-message-actions-ux-design.md(§9 修订)。

四项改动:

  1. 收敛编辑入口:删除行内铅笔 mopsUserEdit / ensureEditBtn / runUserEdit、 面板 panel.edit 一组与 startEdit、client.js 里的老 setDraft 路径、 相关 i18n 与派生状态。用户消息动作行收敛为 时间 → 宿主Copy → | 回撤 → 分叉。 编辑并重发交由已装的专门插件在 turn-tail 提供。

  2. 删 AI 时钟按钮:client.js 里那个 IconClock = runRevert(回滚到此条), 与用户消息回撤重复。连 runRevert / IconClock 定义一并删除。 AI 消息动作区只留一个引用按钮。

  3. 引用重做:

    • 图标不再回退 IconCopyOutlineRegular(会与宿主自带 Copy 撞脸)
    • 优先引用用户在消息里选中的文字;无选中才取整条,但截断到 600 字 并标注 引用自 seq N(走 i18n quote.sourcePrefix)
    • 仍走宿主官方 insertQuote(结构化 QuoteNode → 模型可见 markdown blockquote + QuotePreview 可视化 + 删除按钮),不手工拼 >
  4. dock 不再自动折叠:删掉「markers 变化 → setOpen(false)」的 effect。 折叠只由用户点击改变;恢复=重放(新 seq、不回原位)的语义与文案不变。

测试 313(309 pass / 4 skip),prettier --check 全绿。

版本与发布记录

@240xu/dsh-message-ops 共发布 47 个版本,首次发布于 2026/09/28;过去 12 个月发布了 47 个版本。

版本发布日期距今
0.19.0latest2026/11/07今天
0.18.02026/11/061 天前
0.17.02026/11/061 天前
0.16.02026/11/061 天前
0.15.02026/11/061 天前
0.13.12026/11/043 天前

关于 @240xu/dsh-message-ops 的常见问题

@240xu/dsh-message-ops 是什么?

DSH web 插件:消息级回滚 / 删除 / 恢复 / 分叉 / 编辑重发(补 dsh 官方没有的部分)。

如何安装 @240xu/dsh-message-ops?

在项目目录运行 npm install @240xu/dsh-message-ops,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@deepseek-ai/dsh-tools(^0.1.0-rc.6 || ^0.2.0-rc.1)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@240xu/dsh-message-ops 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/240xu__dsh-message-ops 是否存在。

@240xu/dsh-message-ops 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@240xu/dsh-message-ops 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=23.5。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@240xu/dsh-message-ops 可以商用吗?

@240xu/dsh-message-ops 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@240xu/dsh-message-ops 的下载量有多少?

根据 npm 官方下载统计,@240xu/dsh-message-ops 在 2026/10/05 – 2026/11/03 这 30 天内被下载 5,475 次,日均约 183 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@240xu/dsh-message-ops 还在维护吗?

最新版本 0.19.0 发布于 2026/11/07(今天);至今共发布 47 个版本,其中过去 12 个月发布了 47 个;首次发布于 2026/09/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。