dsh-midtalk
任务内插话 + 可选打断 + 结构化恢复卡——给 DeepSeek Harness(DSH)加两条斜杠命令,把"我中途想说一句"和"我要立刻停住这一步"分开,并且打断的时候不丢现场。
English · 中文
- 纯 host 侧插件(Cordis),零客户端代码、零构建、无第三方运行时依赖;对宿主包
@deepseek-ai/dsh-llm 有一个 peer 依赖——只有 index.js 用它来构造注入消息,lib/core.js 与自测都不需要它。
- 不注册任何模型能力、不联网、不上报任何数据;只在
$DSH_HOME/midtalk/ 下写自己的启动标记、送话日志与恢复卡。
- 不经 npm 分发:
package.json 里 "private": true,装法是"把目录放进 profile"(见 §3),不是 npm i。
1. 它解决什么问题
任务进行中你想插一句话,本来只有两个极端:
| 做法 | 代价 |
|---|
| 等这一轮跑完再说 | 慢,而且长步骤可能几分钟 |
| 按界面停止按钮 | 立刻,但当前这一步被砍掉,可能留下半截状态,且你刚才那句话没人接 |
本插件把这两件事拆成两条命令,由你按一次回车来决定,不做任何"智能升格"(不按步长自动改成打断——这条是明确的设计选择,理由见 §6)。
2. 两条命令
| /say <你要说的话> | /cut [你要说的话] |
|---|
| 语义 | 无损:插到下一个步骤边界 | 有损:中止当前这一步 |
| 打断? | 不打断 | 打断(在跑的工具调用被取消) |
| 延迟 | = 当前这一步的剩余时长(步很短就几乎立刻;步很长就一直等) | 等 agent 回到 idle 再送话,兜底 3 秒(本机一次观测 27 ms,取证见 $DSH_HOME/midtalk/wake.log) |
| 轮次 | 留在同一轮里回应 | 当前轮结束,你的话作为新一轮到达 |
| 残余状态 | 没有 | 可能有(被打断的那一步) |
| 恢复 | 不需要 | 自动写一张结构化恢复卡 |
| 什么时候用 | 默认就用它 | 我卡在一个很长的步骤里、你必须现在停我 |
两条命令的返回文本都会附一句「最近一个工具调用开始于 X 秒前(估量)」——只给信息,不改语义:你看一眼就知道该不该改用 /cut。
3. 安装
本插件在 package.json 里声明了 dsh.bundle.patch(指向仓库根的 cordis.patch.yml),所以 DSH 自带的插件命令能把它按图层装进某个 profile;也完全可以手工放进去(方式 B)。
两个文件必须一起在包里:index.js 与 lib/core.js——index.js:44 是对 ./lib/core.js 的相对导入,少一个就挂载失败。
方式 A:dsh plugin add(推荐,需要重启 DSH)
dsh plugin --profile web add "github:blueberrymaid/dsh-midtalk"
这是 pnpm 的薄转发(@deepseek-ai/dsh/lib/plugin-Ddi42qoW.js:7-17):它在 profile 目录里跑 pnpm add <你的 spec>,然后按已安装状态核对 profile 的图层列表——某个依赖解析到的包若声明了 dsh.bundle,就被加进 dsh.profile.bundles;否则只当普通依赖装下并给一句警告(:25-33、:46-78)。所以 package.json 里的 dsh.bundle.patch 不是装饰:没有它,插件装上了也不会成为 profile 图层。装完重启 dsh web 生效。
方式 B:手放目录 + 名册(不依赖 pnpm / 网络)
第 1 步:放到模块目录
# 从 GitHub 取
git clone https://github.com/blueberrymaid/dsh-midtalk.git dsh-midtalk
把整个目录(至少 index.js + lib/core.js)放进:
$DSH_HOME/profiles/<profile>/node_modules/dsh-midtalk/
$DSH_HOME 默认是用户主目录下的 .dsh;Windows 上实际路径形如
C:\Users\<你>\.dsh\profiles\web\node_modules\dsh-midtalk\(profile 名通常是 web,在你自己的机器上以 $DSH_HOME\profiles\ 下的目录名为准)。
第 2 步:挂上名册
在 $DSH_HOME/profiles/<profile>/cordis.patch.yml 里加一行 insert:
- insert:
- id: midtalk
name: dsh-midtalk
改之前先备份该文件(例如 Copy-Item cordis.patch.yml cordis.patch.yml.bak)。这个文件是启动时一次性读取、全或全无:写坏一行会让 dsh web 整个起不来。
name 也接受相对/绝对路径写法(例如 ./node_modules/dsh-midtalk/index.js)。
第 3 步:重启 dsh web,然后做下面的确认。
怎么确认装上了
读 $DSH_HOME/midtalk/boot.json——插件在 apply() 与命令注册完成时各写一次(index.js:210、index.js:235):
{ "plugin": "dsh-midtalk", "version": "1.0.0", "stage": "registered", "commands": ["say", "cut"], "pid": 18092 }
version 必须等于你装的版本;stage 走到 registered 才算命令注册完成。
pid 是写下该文件的进程。版本没变或 stage 停在 apply,说明活进程跑的不是你刚放进去的代码——ESM 模块缓存会喂回旧版本,重启最能保证清掉它。
- GUI 输入
/ 时应能看到 /say 与 /cut。
附录:免重启方式(dsh-my-guardian 候选区,本机实测)
装了 dsh-my-guardian 的机器可以改候选文件让它热挂载、不重启。下面是作者本机实测过的流程,不是通用要求:
- 把包放进
$DSH_HOME/profiles/web/node_modules/dsh-midtalk/;
- 往
$DSH_HOME/profiles/web/cordis.staged.json 写一行:
[{"id":"midtalk","name":"./node_modules/dsh-midtalk/index.js?v=1"}]
- 几秒内 guardian 会试挂:成功即从候选文件移除、并把条目写进
$DSH_HOME/guardian/state.json 的 promoted;失败会保留候选行并在 lastError 里给原因(连续失败 3 次会冻结)。
两条实测出来的坑:
- 换模块名必须同时换
id:只换 name、沿用旧 id,guardian 完全不受理(候选行原样留着、不报错、不挂载)。
- 同一个路径重挂不会换代码:ESM 模块缓存会让活进程继续跑旧代码(实测:
dsh-progress-report 三次安装零生效)。跳过缓存只有一个杠杆——给模块名加查询串,如 ?v=2;再用 boot.json 的 version 核对。
卸载 / 回滚
- 从
cordis.patch.yml 删掉那行 insert(guardian 环境里也可以 POST http://127.0.0.1:3080/guardian/api/remove,body {"id":"<登记的 id>"},立即热卸载);
- 删掉
$DSH_HOME/profiles/<profile>/node_modules/dsh-midtalk/;
- 可选:删
$DSH_HOME/midtalk/(内含 boot.json、wake.log、last-interrupt-*.json 恢复卡——想留就留,它们是纯文本)。
4. 恢复卡
/cut 在打断前把现场写到 $DSH_HOME/midtalk/last-interrupt-<会话 id>.json(每个会话一张,不会互相覆盖;原子落盘:临时文件 + 同目录 rename,所以不会出现半截 JSON)。字段:
| 字段 | 含义 |
|---|
at / reason | 时间 / 触发原因(cut) |
plugin / version | 写入者与版本(用来判断"活进程跑的是哪一版") |
agentStatus / sessionId | 打断时的 agent 状态与会话 |
interruptedAt | {turn, step, attemptId}——被打断的位置 |
userText | 你附带的那句话 |
pendingToolCalls | 正在派发/刚派发的工具调用:{at, attemptId, callId, name, arguments}(arguments 已解析成对象,不是转义字符串) |
lastReasoning / lastText | 我被打断前的推理/正文片段(各截 2000 字符) |
usage / lastFinish | 那一刻的 token 用量与结束原因 |
diagnostics | 帧类型计数、解析失败数——出问题时用它判断帧格式是否变了 |
frames | 最近 12 个流帧(环形窗口,便于回看现场) |
恢复协议(打断后照做,只依赖这张卡,不需要别的文件):
- 先列状态、不继续干;
- 读这张卡,重点是
pendingToolCalls:它列出被打断那一刻正在派发/刚派发的工具调用(名字 + 参数);
- 逐个判断"是否已落地"——卡里给的是调用意图,不是落地清单,所以要自己核实它碰的文件/命令(读文件、看退出码、比对修改时间);
- 把判断结果(已落地 / 需回滚 / 先验证)交用户拍板;
- 用户确认后重做被打断的那一步。
理由:agent.cancel 不会撤销已经落地的写入。
5. 硬规则行(只进插件)
两条命令注入的正文末尾都带一行:
〔硬规则 · 收到插话先输出一句正文回复(确认收到 + 回应内容),再决定是否继续调用工具;只发工具调用、或不回话=违规〕
它写死在 lib/core.js:16,不进宿主每轮注入的 AGENTS.md:这是本插件自己的行为约束,不该污染全局。出处是一次真实事故:收到插话后只发工具调用、不写正文,用户连问两次都没得到回应。它是文本约束,没有执行层强制力——但至少让"被问到却不回话"变成明确违规,而不是风格问题。
6. 它不做的事(故意的)
- 不杀后台:
jobs.kill / terminals.kill / subagents.interrupt / goals.disarm 一个都没接。杀后台本身就会制造半截状态(正在下载、正在写文件的活),与本插件"不丢现场"的目标冲突。
- 不做阈值自动升格:不去看"当前步跑了多久然后自动改成打断"。那会把你唯一的保证(
/say 永不破坏)变成由你看不见的状态决定的条件保证,还剥夺你的选择权。
- 不隐藏括号内容:注入正文里的行为说明会显示为系统行(
source: {kind:"plugin", form:"notice"}),不是伪装成你的消息,但看得见。想彻底隐形需要不被渲染的通道,本插件做不到。
- 不撤销已落地的写入(系统层没有回滚这一说)。
7. 依赖的未公开内部接口
本插件的功能建立在 DSH 的内部事件与 API 形状上。下面每一行都在源码里核对过;实测 DSH 版本:@deepseek-ai/dsh 0.1.5-rc.3、@deepseek-ai/dsh-llm 0.1.5-rc.3(版本号读自这两个包的 package.json:4)。升级 DSH 后这些接口都可能变,一变本插件就静默失效(不会有编译错误,因为它是动态挂载的 JS)。
| 用途 | 依据(包名 + 文件:行) |
|---|
| 无损投递 | agent.steer(createUserMessage(...));createUserMessage 来自 @deepseek-ai/dsh-llm(index.js:25 的顶层 import,index.js:177 使用) |
| 有损投递 | agent.cancel({kind:"user"}, {keepInbox:true}),与界面停止按钮同一调用:@deepseek-ai/dsh-api-session-controller/lib/index.js:872-878(本插件在 index.js:198 调它) |
| 送话时机 | agent.status getter(`idle |
| 恢复卡内容 | agent/assistant-stream 帧(订阅在 index.js:211);frame.chunk 是对象(StreamChunk)而不是字符串(lib/core.js:65-70 兼容两种) |
| 系统行显示 | source: {kind:"plugin", plugin, form:"notice", summary}(样板:@deepseek-ai/dsh-agent/lib/index.js:99-114;本插件在 index.js:148-153) |
| 命令注册 | ctx.commands.register({name, description, input, handler}):@deepseek-ai/dsh-commands/lib/index.js:257-259;重名会抛错:同文件 :82(且没有公开的 unregister API——换版本要先卸载旧插件腾出命令名) |
| 命令调用载荷 | handler 收到 {agent, rawInput, ...}(本插件 index.js:166-171) |
8. 已知边界
/say 的延迟 = 当前这一步的剩余时长:我跑一个 100 秒的单步,你就会 100 秒收不到回应(实测过;对策是把长活拆成短步,或改用 /cut)。
- 待送话队列上限 8,超出会静默丢最早的:
MAX_PENDING_WAKES = 8(lib/core.js:20)。同一个 agent 堆积超过 8 条待送话时,最早的会被丢弃(index.js:132-143),只在 $DSH_HOME/midtalk/wake.log 留一行 "why":"dropped:queue-full"——不会提示用户。正常用法(一次一条 /cut)碰不到,连续快速 /cut 才会。
- 恢复卡给的是工具调用的意图,不是"是否已落地"的清单——后者结构上拿不到,靠流程兜(原子写入、只碰可整体删除的容器)。
- 一步里超过 20 个工具调用时只保留最后 20 条(
lib/core.js:26)。
lastText / lastReasoning 各截 2000 字符(lib/core.js:24-25),frames 环形窗口只有 12 帧(lib/core.js:21)。
- 空
/say(不带内容)会注入一条无内容的插话。
- 兼容性只在实测 DSH 版本(0.1.5-rc.3)上验证过:2026-09-25,Windows + node 22。Linux / macOS 未实测(代码里没有平台相关分支,但没跑过)。
9. 开发
dsh-midtalk/
├── index.js 宿主半边:命令注册、cancel、写盘、事件订阅
├── lib/core.js 纯逻辑:流帧→trace、trace→恢复卡对象、正文拼装(零依赖,自测只跑这一层)
├── test/plugin.spec.mjs 自测(零框架,只用 node 内置模块)
├── .github/workflows/test.yml CI:ubuntu-latest + windows-latest × node 22
├── .gitignore / .gitattributes
├── cordis.patch.yml dsh.bundle.patch 指向的图层补丁(方式 A 靠它安装)
├── package.json
├── README.md / README.en.md
└── CHANGELOG.md / LICENSE
node test/plugin.spec.mjs # dsh-midtalk 自测:38 passed, 0 failed
自测只覆盖 lib/core.js(不 import DSH 包、不碰文件系统、不写 .dsh),所以在任何地方、任何平台都能跑,也不需要 npm install。
10. License
MIT(见 LICENSE)。