@240xu/dsh-message-ops
Message-level rollback, single-message deletion, and replay-based restore for DSH sessions; operations are append-only surface replacements, with confirmation prompts and a running-session guard. Requires Node.js 23.5+.
Install
dsh plugin --profile web add @240xu/dsh-message-ops
Sandbox test results
| Stage | Result |
|---|---|
| 1 install | ✓ |
| 2 list | ✓ |
| 3 mount | ✓ |
| 4 boot | — |
Four stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).
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 秒速查
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 操作)
##
…(truncated)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::46,最后更新 2026-11-06