# @240xu/dsh-message-ops 中文文档

规范地址：https://npmcn.com/package/%40240xu/dsh-message-ops/

版本：0\.19\.0
npm 元数据更新时间：2026-11-07T05:57:01.106Z
中文译文更新时间：2026-11-07T05:59:10.796Z
软件包许可：MIT

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40240xu/dsh-message-ops
原文对照：https://npmcn.com/package/%40240xu/dsh-message-ops/?lang=original
项目主页：https://github.com/240xu/dsh-message-ops#readme
源码仓库：https://github.com/240xu/dsh-message-ops

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：454 kB，9 个文件
- 模块格式：ES Module
- TypeScript 类型：未声明
- Node.js 要求：\>=23\.5
- 运行依赖：0 个
- peer 依赖：@deepseek-ai/dsh-tools ^0\.1\.0-rc\.6 \|\| ^0\.2\.0-rc\.1
- 首次发布：2026-09-27T22:00:46.983Z
- 最新版本发布：2026-11-07T05:57:00.969Z
- 近 30 天下载量：5475（2026-10-05 至 2026-11-03，npm 官方下载统计）
- 已发布版本数：47（近 12 个月 47 个）

## 中文简介

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

## 安装

```sh
npm install @240xu/dsh-message-ops
```

也可以使用：`pnpm add @240xu/dsh-message-ops`、`yarn add @240xu/dsh-message-ops`、`bun add @240xu/dsh-message-ops`

## README

# @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 秒速查

```sh
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` 的**前一个**事件：
    
    ```js
    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 通用）。

```sh
# 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 读取端两种拼写都兼容，前向迁移无需改动。

### 开发

```sh
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` / `steering` | messageId | `byMessageId` |
| `assistant-step` | `<turn>:<step>` | `byTurnStep` |
| `tool-call` | callId | `byCallId` |
| `turn-tail` / `turn-process` / `turn-error` | turn 号 | `byTurn` ← **turn/end 的 seq** |
| `developer-message` | messageId | `byMessageId` |

配合服务端的 `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 状态机全由宿主负责。

```js
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`

**旧实现是错的**（用户直觉对：「这样真的行吗」）。查宿主源码：

```js
// 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 的旧实现是：

```js
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，只对齐视觉规格）**：

| 项 | 改前 | 改后 | 依据 |
| --- | --- | --- | --- |
| 图标尺寸 | 15px | **14px** | opencode `size="small"` |
| 按钮圆角 | 6px（宿主默认） | **4px** | `icon-button.css:29-33` |
| 图标颜色 | `label-tertiary`(#9a9aa0) | **#808080** | `--v2-icon-icon-muted` |
| hover 前景 | 不变色 | **转 `label-secondary`** | `icon-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 里取证，不是推测）：**

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

对比 `user/message` 的 match：

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

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

```js
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)`）渲染的是：

```html
<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/*` 规则一致）：

| 规则 | 值 |
| --- | --- |
| `semi` | `never`（无行尾分号） |
| `quotes` | `single`（`avoidEscape`） |
| `printWidth` | `140` |
| `tabWidth` | `2` |
| `trailingComma` | `all` |
| `arrowParens` | `as-needed`（单参数去括号） |

`npx prettier --check "src/*.js"` 现在全绿；`npm test` 267（263 pass / 4 skip）。

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

```js
// 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/dsh-client-ui-message-edit@0.4.14`，它注入 `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 是什么？

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 和提交记录判断。

