# DSH 回滚插件:踩过的雷(经验总结) > 用途:后面重新做前端可视化 / 维护后端三件套时,避免重复踩坑。 > 每条雷包含【现象】【根因】【正确做法】。按"本次放弃的前端"→"已解决的后端"→"命令路由"三部分整理。 --- ## 一、前端可视化(本次放弃,DOM 注入方案) ### 雷 1:渲染层选了 DOM 注入,而不是轨迹原生节点 - **现象**:锚点徽标钉在 `section[data-turn]` 上,React 一重渲染徽标就掉,得靠 MutationObserver 反复重钉("钉了掉、掉了钉");点击事件绑在随时被移除重建的节点上,时灵时不灵(用户实测:点小点点没反应)。 - **根因**:内置轨迹视图的节点 kind 分发是**封闭 if 链**(`if (node.kind === "user") … else if (node.kind === "assistant") …`,无 default 分支),外部插件注册新 kind(如 `rollback-anchor`)会被所有 if 静默跳过、不渲染。于是绕去用 MutationObserver 往 React 管的 DOM 里塞节点。 - **正确做法**:改 `trajectory` 源码,在渲染器的 kind 分发链里**加一个 `rollback-anchor` 分支**,锚点成为轨迹的一等公民(继承布局、折叠、tooltip、分页)。数据层不用动。 ### 雷 2:数据层其实写对了,别推倒重来 - **现象**:`conversationEvents.register` + `conversationViews.register` 把 `rollback/checkpoint`、`rollback/start|end` 折成锚点快照,工作正常。 - **正确做法**:这两个是**官方扩展点**(trajectory 内置 8 个定义全走这套),`src/client/anchors.ts` 的 checkpoint/transaction 定义**直接可复用**。放弃的只是渲染层(overlay.ts 的 DOM 注入)。 ### 雷 3:client 插件作为 bundle 挂载,破坏了 host 插件加载 - **现象**:用 `dsh plugin add` 把前端插件加为 profile 的**第三个 bundle** 后,`command-rollback` 的 `/rollback` 命令**不再注册**(用户输入 `/rollback` 被降级成普通消息)。而 rollback-basic 正常(checkpoint 照写)。 - **根因**:bundle 加入改变了 profile 加载结构/时机,`command-rollback` 的 `inject: ['commands','rollback']` 在加载窗口期没被正确满足 → 它的 `apply()` 没跑 → 命令没注册。前端插件 host 侧虽是无害空壳 `apply(){}`,但"作为 bundle 挂载"这个动作本身是破坏源。 - **教训**:client 插件不要作为 profile bundle 挂载;要隔离加载(单独 patch 层 / 独立作用域),别让它改变 host 插件的加载顺序。 - **已坐实**(2026-08-15):从 profile 移除 ui-rollback-visual bundle 并重启后,`/rollback` 命令立即恢复(日志出现 `command/run {name:"rollback"}`),确认本雷即根因。 ### 雷 4:`childSessionId` 拿不到(自动激活链路缺一环) - **现象**:`commands.execute('rollback')` 返回的 `CommandResult` 只有 `{ kind, text, sourceEventSeq }`,子会话 ID 埋在 text 字符串里("new child session xxx")。 - **正确做法**:给 `command-rollback` 的返回加一个结构化 `childSessionId` 字段,client 拿到后直接 `sessions.open(childId)`。 --- ## 二、后端三件套(已解决,但机制重要) ### 雷 5:会话事件白名单机制(之前 resume 崩溃的真凶) - **现象**:插件写了 `rollback/*` 自定义事件后,重启恢复会话直接崩("unknown event type, refusing to interpret the log")。 - **根因**:`KNOWN_SESSION_EVENT_TYPES`(`packages/core/session/src/known-event-types.ts`)是**生成物**(`scripts/gen-persistence-catalog.ts`),只收录"仓库内声明过"的 `SessionEventMap` 成员。持久化读取路径 `assertEventsSupported` 遇到白名单外、又没打 `ignorable` 标记的事件就拒绝。仓库外/未合入插件的事件天然不在列表。 - **正确做法**:插件用 `declare module '@deepseek-ai/dsh-session/types'` 声明事件后,跑 `pnpm run gen-persistence-catalog` 重新生成白名单,让事件进 `KNOWN_SESSION_EVENT_TYPES`。 ### 雷 6:`Session.append` 没有 ignorable 通道 - **现象**:想让事件"可被安全跳过",但 `append(type, data, ...opts)` 的 `opts` 只对 surface 事件有意义,**没有参数能设 `ignorable`**。 - **正确做法**:自定义事件要么进白名单(雷 5),要么接受"必选"语义。别指望运行时给 append 打 ignorable。 ### 雷 7:Service Definition 不能单独加载 - **现象**:把 `@deepseek-ai/dsh-timeline-rollback` 也写进 `cordis.patch.yml` 加载 → `service "rollback" has been registered` 冲突。 - **根因**:timeline-rollback 是纯 Service Definition(抽象类 `RollbackEngine` + 事件词汇表),不是可加载插件。loader 把 default export 当插件实例化,`new RollbackEngine(ctx)` 注册了 `ctx.rollback`,随后 rollback-basic 的 `BasicRollbackEngine` 又注册一次 → 冲突。 - **正确做法**:只 insert Provider(`rollback-basic`)和 Consumer(`command-rollback`),Service Definition 作为它们的依赖自动引入,**不要**单独加载。 --- ## 三、CLI 命令路由(最新发现) ### 雷 8:命令没注册时,输入被静默降级成消息,无任何报错 - **现象**:`/rollback` 命令没注册时,用户输入 `/rollback checkpoint` 不会报"未知命令",而是**当作普通消息发给模型**,很难察觉。 - **根因**(GUI 路由链路): 1. composer 检测到 `/` 前缀 → 进入「裁决」(adjudicate); 2. `matchEnter` 解析 token,调 `directory.resolve(sessionId, name)`; 3. directory 缓存来自 `commands.list(agent)`(Remote RPC)→ host `commands.list`; 4. `resolve` 返回 `undefined`(命令不在列表)→ 裁决结果 `void 0` → 降级成普通消息。 - **排查方法**:查会话日志里有没有 `command/run`(`name: "rollback"`)——0 次就是命令没注册;有 `command/run` 才是路由到了命令系统。 - **教训**:验证命令是否生效,先看日志里有没有 `command/run`,而不是只看"命令没报错"。 ### 雷 9:带参数的命令必须声明 `input`(leadingInput),否则参数输入被降级成消息 - **现象**:`/rollback`(无参数)能执行,但 `/rollback checkpoint`、`/rollback checkpoint:`、`/rollback `(带参数)全部被降级成普通消息。 - **根因**(GUI 裁决逻辑 `matchEnter`):命令输入解析后,`bare`(无空格)的命令走 `runDetached` 直接执行;但**非 bare**(带参数)的命令,只有 `desc.input !== void 0` 时才返回 claim(认作命令),否则 `if (!bare) return void 0` → 降级成消息。而 `CommandDefinition` 的 `input` 字段是 `{ hint: string }`(leadingInput 声明),command-rollback 注册时**没声明 input** → 带参数一律降级。 - **正确做法**:注册命令时声明参数提示: ```ts ctx.commands.register({ name: 'rollback', description: 'Fork a child session through a history boundary', input: { hint: '>' }, // ← 关键 handler, }) ``` - **已修复**(2026-08-15):command-rollback 加上了 `input` 声明(源码 `38edcee3f7`),`/rollback ` 系列命令恢复正常。 - **参照**:内置命令 hint 写法——`input: { hint: "" }`(compact)、`input: { hint: "[|clear|edit |pause|resume]" }`(goal)。 ### 雷 10:dsh 加载插件走 `.dsh\profiles\node_modules`,不是全局 `dsh\node_modules` - **现象**:改了全局 `C:\Users\\AppData\Local\nvm\\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\dsh-command-rollback` 的 lib 后,重启验证发现改动**没生效**(诊断 `hasInput=false`)。 - **根因**:dsh loader 解析 `@deepseek-ai/dsh-*` 包走的是 profile 的共享 node_modules(`C:\Users\\.dsh\profiles\node_modules`),不是全局 dsh 的 node_modules。这个目录里: - `dsh-session` 等 session 相关包是 **Junction**(链接到全局)→ 改全局的 dsh-session 生效; - `dsh-command-rollback` / `dsh-rollback-basic` / `dsh-timeline-rollback` 是**独立目录**(当初 `dsh plugin add` / 手动复制进来的)→ 改全局的这三包**不生效**。 - **教训**:改任何 `@deepseek-ai/dsh-*` 包的产物前,先确认它**实际加载的副本**在哪(`Read-Item` 看 LinkType:Junction=改全局源,独立目录=改这个目录本身)。用 `Get-ChildItem -Recurse -Filter "xxx"` 全盘搜同名包,别想当然。 - **验证方法**:改完后用动态插件在真实进程里查 `commands.find(agent, name)` 的实际返回(或查日志 `command/run`),确认改动真的进了运行态。 --- ## 附:验证命令是否注册的最小脚本 ```bash # 在全局 dsh 的 node_modules 目录下模拟加载,确认代码本身没问题 cd /node_modules/@deepseek-ai/dsh/node_modules node --input-type=module -e " import { Context } from '@deepseek-ai/cordis'; const { default: BasicRollbackEngine } = await import('@deepseek-ai/dsh-rollback-basic'); const ctx = new Context(); ctx.provide('sessions', { create: () => ({}), get: () => undefined, fork: async () => ({}), flush: async () => {} }); ctx.provide('commands', { register: (d) => { console.log('注册命令:', d.name); return () => {}; } }); new BasicRollbackEngine(ctx, { checkpoints: false }); console.log('rollback 服务已注册:', ctx.get('rollback') !== undefined); const { apply } = await import('@deepseek-ai/dsh-command-rollback'); apply(ctx); " ``` 脚本能打印「注册命令: rollback」且「rollback 服务已注册: true」,说明代码正确,问题在**加载环境**(inject 时序 / bundle 结构),不在代码。