Skip to content
dsh.fish
Bundle

dsh-midtalk

任务内插话与可选打断:/say 无损递话(插到下一个步骤边界),/cut 中止当前步并留下结构化恢复卡

Source
blueberrymaid
stars
1 stars
License
MIT
Updated
Updated 12 days ago

Readme

# dsh-midtalk

**任务内插话 + 可选打断 + 结构化恢复卡**——给 DeepSeek Harness(DSH)加两条斜杠命令,把"我中途想说一句"和"我要立刻停住这一步"分开,并且**打断的时候不丢现场**。

[English](README.en.md) · **中文**

- 纯 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)

```bash
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 步:放到模块目录**

```bash
# 从 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:

```yaml
- 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`):

```json
{ "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` 的机器可以改候选文件让它热挂载、**不重启**。下面是作者本机实测过的流程,**不是通用要求**:

1. 把包放进 `$DSH_HOME/profiles/web/node_modules/dsh-midtalk/`;
2. 往 `$DSH_HOME/profiles/web/cordis.staged.json` 写一行:
   ```json
   [{"id":"midtalk","name":"./node_modules/dsh-midtalk/index.js?v=1"}]
   ```
3. 几秒内 guardian 会试挂:成功即从候选文件移除、并把条目写进 `$DSH_HOME/guardian/state.json` 的 `promoted`;失败会保留候选行并在 `lastError` 里给原因(连续失败 3 次会冻结)。

**两条实测出来的坑**:

- **换模块名必须同时换 `id`**:只换 `name`、沿用旧 `id`,guardian **完全不受理**(候选行原样留着、不报错、不挂载)。
- **同一个路径重挂不会换代码**:ESM 模块缓存会让活进程继续跑旧代码(实测:`dsh-progress-report` 三次安装零生效)。跳过缓存只有一个杠杆——**给模块名加查询串**,如 `?v=2`;再用 `boot.json` 的 `version` 核对。

### 卸载 / 回滚

1. 从 `cordis.patch.yml` 删掉那行 insert(guardian 环境里也可以 `POST http://127.0.0.1:3080/guardian/api/remove`,body `{"id":"<登记的 id>"}`,立即热卸载);
2. 删掉 `$DSH_HOME/profiles/<profile>/node_modules/dsh-midtalk/`;
3. 可选:删 `$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 个流帧(环形窗口,便于回看现场) |

**恢复协议(打断后照做,只依赖这张卡,不需要别的文件)**:

1. 先**列状态、不继续干**;
2. 读这张卡,重点是 `pendingToolCalls`:它列出被打断那一刻正在派发/刚派发的工具调用(名字 + 参数);
3. 逐个判断"是否已落地"——卡里给的是**调用意图**,不是落地清单,所以要自己核实它碰的文件/命令(读文件、看退出码、比对修改时间);
4. 把判断结果(已落地 / 需回滚 / 先验证)交用户拍板;
5. 用户确认后**重做被打断的那一步**。

理由:`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 | maintenance` 之外都算 `running`):`@deepseek-ai/dsh-agent-loop/lib/index.js:773-775`;状态变化时发 `agent/status`:同文件 `:776-782`;事件载荷带 agent:`@deepseek-ai/dsh-tool-cordis/lib/index.js:5061-5065` |
| 恢复卡内容 | `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
```

```bash
node test/plugin.spec.mjs     # dsh-midtalk 自测:38 passed, 0 failed
```

自测只覆盖 `lib/core.js`(不 import DSH 包、不碰文件系统、不写 `.dsh`),所以在任何地方、任何平台都能跑,也不需要 `npm install`。

## 10. License

MIT(见 `LICENSE`)。

Install

dsh plugin --profile web add github:blueberrymaid/dsh-midtalk

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source