# dsh-handoff-bus — 会话总线

[English](README.md) | **中文**

给 DSH 加一组 `bus_*` 工具：**让主对话和你在 GUI 里手开的子对话直接互发指令/回报**，不用你切窗口当传话人。

## 它做什么

- ✅ 主对话 → 子对话：`bus_send(target="api", kind="指令", done="…", ...)`；只写线名，自动落到这条线上活着的那个会话
- ✅ 子对话 → 主对话：`bus_send(target="main", kind="回报", ...)`
- ✅ 三级角色：主线 `main` / 线 `api` / 临时会话 `api.1`、`api.2`；换会话不用改主线的写法
- ✅ 按会话所在目录自动分总线；一个目录一条独立总线
- ✅ 替人记着三件事：新会话自动收到「开工简报」、上下文到 40% 自动提示归档换班、收工不写台账会被拦住

## 它不做什么

- ❌ **不创建会话**：新对话只能你在 GUI 里点。插件只负责登记和路由
- ❌ **不关闭会话**：该归档时它只给信号（`suggestHandoff`），关不关由你决定
- ❌ **不压缩上下文**：DSH 的压缩由宿主触发，插件既读不到水位也改不了阈值
- ❌ 不监听文件、不设定时任务、不在你不在场时自动开工
- ❌ 不替代你的状态文件：任务文件、回报仍由对话自己写，总线只负责送达和叫醒
- ❌ 不跨工作区寻址（每个目录一条总线）
- ❌ 不能向子代理会话发送消息：目标必须是**顶层 GUI 会话**。作为 subagent 跑在别的会话下面的会话由宿主路由接管，在里面注册也发不进去，`bus_send` 会返回 `target_unresolvable`

## 工作流

```
你 ──打开主对话──▶ main（bus_register role="main"）
                     │
                     │ ① 写任务文件 / 指令文件
                     │ ② bus_send(target="api", kind="指令", done="…", message="…")
                     ▼
                  api 线 ──▶ api.1（bus_register parent="api"）  ← 被唤醒
                     │ ③ 干活，写回报文件
                     │ ④ bus_send(target="main", kind="回报", ref="R-…")
                     ▼
                  main（攒够几条回报才被叫醒一次）
                     │ ⑤ api.1 回报完就待命；只有上下文满了（或你说收工）才解绑归档，
                     │    由排队的 api.2 接手
                     ▼
                  api.2（bus_register parent="api"）
```

总线消息正文只写**摘要 + 文件指针**，权威内容在文件里。

## 安装

### 从 npm（推荐，发布后可用）

1. DSH 左侧栏「插件」页 →「添加插件」→ 搜索 `dsh-handoff-bus`
2. 安装后「立即启用」
3. **完全退出客户端（含托盘）再打开**（host 插件需要重启）

或者：

```sh
dsh plugin --profile <profile> add dsh-handoff-bus
```

> CLI 命令针对 CLI 启动的 profile；桌面版的 profile 由客户端自己管理，请在插件页安装。

### 从源码

1. clone 本仓库
2. 插件页 →「添加插件」→ 填**仓库目录的绝对路径**
3. 启用 → 完全退出客户端再打开

> 外链安装的插件位于 profile 的 `node_modules` 之外，解析不到 `@deepseek-ai/*`；本包只 import Node 内置模块，所以两种安装方式都能跑。

## 快速开始

1. 主对话：`bus_register(role="main")`
2. 每个子对话：`bus_register(parent="api")` → 自动拿到 `api.1`
3. `bus_list` 查看角色树与路由
4. 主 → 线：`bus_send(target="api", kind="指令", done="…", message="…")`
5. 线 → 主：`bus_send(target="main", kind="回报", ref="R-001", message="…")`

角色名是小写字母、数字、点、下划线、连字符，最多三层（`main` / `api` / `api.1` / `api.1.worker`）。

`ref` 原样透传，工具不校验格式。示例里的约定是：指令用 `D-`、回报用 `R-`、状态询问用 `Q-`，后面跟项目内的编号。你选一套保持前后一致即可 —— 它只会出现在消息头里，含义由接收方按约定理解。

| 用途 | 调用 |
|---|---|
| 主 → 线派活 | `bus_send(target="api", kind="指令", done="npm test 全绿", message="…")` |
| 子 → 主回报 | `bus_send(target="main", kind="回报", ref="R-…", message="…")` |
| 卡住了，要主对话立刻处理 | 同上加 `urgent: true` |
| 主问进度（不派活） | `bus_send(target="api", kind="请求", ref="Q-…", message="只回当前状态，不要开工")` |
| 只想排队、不叫醒 | 加 `wake: false` |

## 三级角色

| 层 | 例子 | 谁维护 | 要不要换 |
|---|---|---|---|
| 1 主线 | `main` | 人在主对话里注册一次 | 不换。整条总线只有这一个 |
| 2 线 | `api`、`web`、`docs`、`data` | 第一次有子会话注册 `parent="api"` 时自动出现（虚拟节点） | **不换**。它只是名字，没有绑定会话 |
| 3 临时会话 | `api.1`、`api.2` | 每个新子对话注册时由插件发号 | **要换**：一个任务一个会话，做完解绑、下次开新的 |

### 注册

```
bus_register(parent="api")                        → role: "api.1"
bus_register(parent="api", note="导出模块拆分")    → role: "api.2"
bus_register(role="main")                         → 主线
bus_register(role="api")                          → 等同 parent="api"，发到 api.N（见下）
```

人只需要在每个新子对话里说一句：

> 注册 api

插件会先给这个会话发一次固定的三步说明（`bus_register(parent="api")` → 读 `handoff/api/` → 回报已接手），所以模型不需要知道你这条线现在到几号，也不需要猜 `role` 和 `parent` 该填哪个。

**线名不会变成会话。** 以前 `role="api"` 能把线名本身绑到一个会话上，但那样路由就会发现线名有会话、直接投给它，编号的替补永远接不到活。现在裸线名传进 `role` 会被当成 `parent` 处理，返回值里用 `coercedFrom` 说明「你说的是线名，我按领号处理了」。

注册成功后会告诉你排在哪：线上还有更小号的空闲会话，你就是**替补**，等它停用或归档才轮到你；线是空的，你就是直接上岗。**这只是说明，不改变排队顺序**——编号小的永远优先。

### 投递时怎么找目标

1. `target="api.2"` → 精确发给它，不猜。
2. `target="api"` → 走这条线的**队列**：候选是「未停用」且「没有未完成指令」的会话，按编号升序，**取编号最小的那个**。逐个试着打开，最多试 `routeMaxAttempts` 个（默认 3）。
3. 一个都用不了 → `line_busy`（有会话，但都在忙）或 `no_available_session`（全部停用，或这条线一个都没有）。

返回里带 `routedFrom: "api"` / `routedTo: "api.2"`，不做黑箱。`bus_list` 的 `routeTo` 在发之前就能看到会落到谁。

> **一条线同一时刻只有一个会话在干活。** 替补席不是并行池：`api.1` 欠着回报时，`api.2` 不会顶上来——指令直接被拒，显式写 `api.2` 也一样。没有 `force`；唯一的例外是 `preempt: true`，它会插进正在跑的那个 turn，强制「停下、先落盘、再换」，并作废被顶掉的那条。

### 交班：把一条线从旧会话交给新会话

```
bus_register(parent="api", replaces="api.1")
```

旧会话被移进 `retired`（保留 `retiredRetentionDays` 天，默认 7 天，方便排查"为什么发不进去"）。如果它身上还有一条指令没回报，返回的 `warning` 会点名那个 `ref`，并提醒新会话**先读台账确认进度，不要从零重做**。

### 什么时候归档一个临时会话

不用你判断，总线会算：它直接读**真实上下文**（压缩次数 + 水位），到线就在该会话空闲时打停用标记。

| | 提示 | 停用（不再接活） |
|---|---|---|
| 压缩次数 | ≥ 1 | ≥ 2 |
| 水位 | > 50% | > 70% |

**压缩次数优先，而且是压倒性的**：压缩这个动作本身就会把水位清零，所以压缩过之后再报的百分比没有意义。压过 2 次就停，压过 1 次只提示（哪怕当时是 78%）。

停用只打标记，会话、上下文、角色位置全保留，路由跳过它而已。而且总线不会越过它去跟用户喊话——它把一句话塞进那个会话自己的历史里，由那个会话对自己的窗口说：「我该归档了，下一个编号是 api.4」。

**任务做完不是归档的理由。** 做完就 `bus_send(kind="回报")`，然后待命；只有上面两条线到了、或者你说收工，才 `bus_unregister`。早退会让这条线空掉，下一条指令就得开新对话、重新喂上下文，比留着它贵得多。

`suggestHandoff` 仍然保留，作为「只是旧了、不是满了」的软提示（40 条消息，或空闲 30 分钟）。`bus_list` 里还能看到 `compactions` 和 `contextPct`。

经验换算：一次工具调用大约让上下文净增 2–3k token，一条子线跑到 150–200 次工具调用就是 40 万上下。**一条线跑 8–10 轮就该交班了**，别等它开始忘事。

### 主对话什么时候归档

主对话也会满，但它的线放宽了：**压缩 ≥ 2 次或水位 > 85%** 才动手。因为它一停，整个工作区就没人在调度了，代价和换个临时会话不是一个量级。

到了线也不硬停，而是进入**收尾**：

- 不再派新指令（再派会返回 `draining`），但照常收回报；
- 不打断任何子线，让它们按自己的节奏做完；
- 后台每 900 秒看一次「是不是所有线都空闲了」，连续 3 次没动静就退避到 1 小时（长任务不值得被反复查）；
- 全部空闲的那一刻，才提醒你：把状态写进台账，归档这个主对话，新开一个说「接 main」。

新主对话 `bus_register(role="main")` 一注册就接管 `main` 这个名字，各子线的绑定和编号都不动，它们回报会自动落到新主对话上。

收尾期间主对话自己再被压缩也没关系——它这时候只需要记住一个字：等。

## 指令必须带完成标准

`kind="指令"` 不带 `done` 会被拒绝（`instruction_incomplete`）：

```
bus_send(target="api", kind="指令",
         message="把导出逻辑拆成独立模块",
         done="npm test 全绿；导出结果与旧实现逐字节一致")
```

`done` 会附在被投递的消息里，子线回报时可以逐条对照。这解决的是最常见的浪费：指令没说清验收 → 子线猜 → 猜错 → 主对话再补一条 → 每条补充都是一整轮目标上下文。

关闭：`requireDone: false`。

## 回报批投与紧急回报

不是每条回报都值得把主对话叫醒一次 —— 主对话通常是上下文最长的那条，一次唤醒最贵。

| 情况 | 默认行为 |
|---|---|
| `kind="回报"`，不写 `urgent` | **不唤醒**。消息直接进主对话历史；攒够 `digestMinCount` 条（默认 3）或等满 `digestMaxWaitSec`（默认 900 秒），插件发**一条汇总**把主对话叫醒一次 |
| `kind="回报"` + `urgent: true` | 立即唤醒 |
| `kind="请求"` | 默认就是紧急（"请求"本来就是在找人拍板） |
| `wake: true` / `wake: false` | 显式覆盖，最高优先级 |

该标 `urgent` 的只有三种：**卡住了做不下去**、**失败了要换方案**、**要主对话拍板**。正常做完、进度 50%、产物路径——都不算。

紧急消息若赶上批投箱里有积压，会把积压一起带走（消息里带一句"另有 N 条更早的回报已先入队"），不让主对话为积压再醒一次。紧急投递仍受限流约束。

实现细节：批投的消息是**先 inject 进历史、不唤醒**，所以即使客户端重启，丢的只是那一次唤醒，消息本身不丢。唯一的定时器只在有待发汇总时存在，且 `unref`，不会拖住进程。

关闭批投：`digestReports: false`（回到"每条回报都立刻唤醒"）。

## 防止指令堆积

每一次投递，都会在目标会话里单独变成一个 turn。主对话在子线还在干活时连发三条指令，
三条都会排队，然后按顺序依次执行 —— 第一条跑的时候，任务文件早就到第三版了。所以：

- **上一条指令没回报时，第二条会被拒绝**（`line_busy`）。正确做法是更新任务文件、等回报、再下发。没有 `force`，也没有超时放行：长任务就占着这条线。确实要抢，用 `preempt: true`——它插进正在跑的 turn，命令对方「停下、半成品落盘」，并作废被顶掉的那条；被打断那条之后回来的回报会标注出来，但不会解除新指令的占线。
- **每条指令自带序号**（消息末尾的「本线第 3 条指令」），并提示接收方用 `bus_list` 的 `latest` 对照。序号小于最新的，说明已被取代：只做最新那条，不要重复劳动。
- **计数和「待回报」状态都从 `deliveries.jsonl` 重放**，所以重启客户端既不会把编号清零，也不会忘掉一条还没回报的指令。
- **序号按实际目标计**：`target="api"` 和 `target="api.2"` 落到同一个会话时共用同一套编号，不能靠换个写法绕过拦截。

配套的子对话提示词（推荐写进子线的开场指令里）：

```text
收到 [BUS 指令] 后，先调用 bus_list 看 latest 里「main->自己角色」的 seq。
如果消息末尾写的「本线第 N 条」小于 seq，说明已被后面的指令取代：
只做最新那条，不要重复劳动，并在回报里说明跳过了哪一条。
```

## 上下文预算与开工简报

会话活得越久，每次请求越贵。这一节的三件事都是**替人记住**的，不需要谁自觉：

| 时机 | 插件做什么 | 默认 |
|---|---|---|
| 会话第一次请求前 | 注入一条「开工简报」：先读哪些 handoff 文件、大文件只读结构+末尾 | `briefing: true` |
| 上下文到水位 | 注入一条「预算」提醒：做完当前任务就停、写台账、叫用户换班 | 40% 提醒 / 70% 要求收尾 |
| 解绑角色时 | handoff 目录最近没被写过就拒绝解绑（`handover_missing`） | 30 分钟内 |

**三条都是向同一次请求里追加一条 user 消息，不额外开 turn、不额外调用模型**——所以它们本身几乎不花钱（每条 100–200 token），省下的是「第一次就把 95 KB 台账整篇读进去」的那几万 token。

开工简报长这样（配置文件里给了 `briefingFiles` / `briefingScan` 才会有）：

```text
[BUS 开工简报] 本工作区有 handoff/ 协作目录：<workspace>/handoff
开工顺序（先读完再动手；大文件不要整篇读）：
1. 全文读：handoff/README.md、handoff/PROTOCOL.md
2. 只读结构与末尾：handoff/a-report.md —— 先 grep -n '^## ' 看标题，再 tail -n 80 看最新回执；
3. 要细节再按标题定位：sed -n 'A,Bp' 文件；不要 cat 整篇，也不要把大段日志贴进对话。
4. 读完先回答：现在什么状态 / 什么在跑 / 要用户拍板什么 / 有没有欠着的回报（bus_list 看 awaiting）。
状态规矩：写回 handoff/&lt;你的角色&gt;.md（.md，不是 .txt；含未回报的指令 ref 与产物路径）。不收工不要注销，做完回报后待命即可。
```

预算提醒（40%）：

```text
[BUS 预算] 本会话上下文约 43%（约 22.5 万 token，按消息字符估算，已含系统提示与工具定义的固定开销）。
把当前这一件事做完，但不要再开新任务、不要顺手改别的模块。做完后：
① 状态写回 handoff/（含未回报的指令 ref 与产物路径）；② 主动提醒用户「上下文已偏高，建议归档换班」；③ 停在这里。
```

**估算口径**：ASCII 约 3.8 字符/token，中文约 1.6 字符/token，再加上 `contextOverheadTokens`（系统提示 + 工具定义，默认 40000）。这是估算，不是宿主的精确值，误差大约一成多；方向对了就够用。

**但估算只是退路。** 已注册的会话，工具返回的 `context` 与停用判定用的是同一个实测值（宿主的投影桶 + 压缩次数）；只有在宿主读不到投影时，才退回上面这套字符估算，并在返回里标 `estimated: true`。两套数不同源会出人命：曾经 `bus_list` 报 7.6%（40000 常量压在空消息列表上），而插件已经按实测的 78% 在准备停用它。

**窗口为什么默认 524288 而不是 1048576**：profile 里声明的窗口是 1M，但实测在 43 万左右就开始 `context overflow`。按 1M 算 40%，提醒点会落到 42 万——那时已经晚了。按实测能用的窗口算，比例才对得上。

## Token 开销

总线本身很便宜，它触发的 turn 才是花钱的地方。

| 项目 | 成本 |
|---|---|
| 五个工具的定义 | **每次请求**约 1400 token，所有会话都带，用不用都算 |
| 消息包装 | 每条约 120 token（消息头、工作区、时间、协议句）+ `done` 几十 token |
| **被唤醒的一个 turn** | **目标会话的整个上下文重新作为输入送一遍** |

真正贵的是第三行。一个已经积累 3 万 token 的会话，被叫醒来读一条 20 字的消息，这次唤醒就要
3 万输入 token；而且这条消息会永久留在它的历史里，这个会话之后每一轮都会略贵一点。

0.3.0 起，下面第 1 条和第 3 条由插件替你记着（开工简报 + 预算提醒 + 收工写盘），不需要人盯。
按收益排序，能做的九件事：

1. **别让一条线跑到底**（收益最大）：上下文到了停用线就换人，别等它开始忘事、算错、反复重读同一个文件。**但「任务做完就退」是浪费**——做完回报后待命，下一条指令还能接着用同一个上下文；退早了，下一条指令就得新开对话、重新喂一遍历史和文件。
2. **别往对话里贴大段日志**：让它写文件、用 grep 定位，只把命中行贴回来。
3. **`done` 写清楚**：省掉"再补一句"带来的整轮上下文。
4. **`urgent` 只留给卡住/失败/要决策**：其余回报走批投，不逐条叫醒。
5. **`wake: false`**：不是「立刻开工」的消息就别叫醒，塞进收件箱，等它自己下次跑。
6. **`kind="通知"` 做状态同步**：纯落盘，不唤醒。
7. **不轮询**：主对话每隔几分钟 `bus_list` 问"到哪了"，每次都是最贵的那份上下文，而且什么也没推进。
8. **只发指针**：正文给摘要 + 文件路径，正文内容留在文件里。
9. **不用的 profile 直接禁用插件**：那 1400 token 的 schema 就不带了。

## 新目录开工（发给主对话的提示词）

总线**自己建目录**。第一次 `bus_register` 就会创建 `handoff/`（以及里面的 `.bus/` 状态子目录）；如果这之前状态存在 DSH 用户目录里，会先搬过来，绑定和编号都保留。

新目录开工：

1. 主对话 `bus_register(role="main")` —— 目录和状态一起就位。
2. 写任务/指令文件，用 `bus_send` 派活。
3. 子对话那边只要说一句「注册 api」，它就会拿到编号、读到目录、回报已接手。

文件约定：状态写 `handoff/<角色>.md`（例如 `handoff/api.2.md`），用 `.md`，不要 `.txt`——`.txt` 不会被开工简报和收工检查认。

下面骨架里的文件名只是示例，可自由改名；数字前缀（如 `40-`）只是可选的排序备注。

```text
在这个目录建立 handoff/ 并按总线协议开工。

第一步：建目录和文件（按本项目已有 handoff/ 的约定；没有就按下面的骨架建）：
  handoff/README.md
  handoff/instructions.md
  handoff/pending.md
  handoff/task-a.md、task-b.md
  handoff/status-a.md、status-b.md（各角色当前状态）
  handoff/report-a.md、report-b.md

第二步：你先 bus_register(role="main", note="<日期> 主对话")，再用 bus_list 确认。

第三步：派活时先写任务/指令文件，再调用
  bus_send(target="api", kind="指令", ref="D-001",
           done="report-a.md 写完，npm test 全绿",
           message="任务文件 task-a.md 已更新；读它和 D-001 段，完成后写 report-a.md，再 bus_send 回 main。")

第四步：子对话只写自己的 status 和 report 文件，不碰别人的文件；
handoff/.bus/ 是总线状态（bindings.json / deliveries.jsonl），不要手写。

现在先建目录和文件，然后注册 main，最后 bus_list 给我看角色表。
```

子对话（每开一个新的就发这一段）：

```text
你是 api 线的新会话。先 bus_register(parent="api") 拿到自己的编号，再读 handoff/api/STATE.md 了解进度。
只写 handoff/api/ 下你自己的 status 和 report 文件；完成后
bus_send(target="main", kind="回报", ref="R-<日期>-01", message="回报已写 report；结论：…；下一步：…")
卡住、失败、需要拍板时加 urgent: true。
```

## 多工作区（在其他目录使用）

- 总线按**调用者会话的 cwd** 自动划分；工作区 A 的 `main` 和工作区 B 的 `main` 互不影响。
- 同一条总线内，主/子必须在同一 cwd 才能互相寻址。
- 状态位置：
  - 目录里有 `handoff/` → `<workspace>/handoff/.bus/`
  - 没有 → `<DSH_HOME>/handoff-bus/<消毒后的目录名>/`
- 跨工作区暂不支持。需要的话可以提 issue。

## 工具

| 工具 | 作用 |
|---|---|
| `bus_register(role?, parent?, replaces?, note?)` | 注册角色。只给 `parent` 时自动发号；裸线名传进 `role` 也会被当成 `parent`（返回 `coercedFrom`）。结果里带 `queue` 顺位说明 |
| `bus_unregister(role?, force?)` | **只在归档（上下文满）或用户说收工时**解绑；做完一个任务不要解绑 |
| `bus_whoami()` | 当前会话的角色 + 工作区 |
| `bus_list()` | 角色树（`parent` / `depth` / `children` / `virtual`）+ 信号（`awaiting` / `msgCount` / `idleMin` / `routeTo` / `suggestHandoff`）+ `latest` |
| `bus_send(target, message, kind?, done?, ref?, urgent?, wake?, preempt?)` | 投递到本工作区目标；只写线名时按编号排队路由，结果里带 `routedFrom` / `routedTo` |

`bus_whoami` / `bus_list` / `bus_send` 的结果会带上 `context`（实测水位 `pct` / `tokens` / `level` / `compactions`；
读不到宿主投影时退回估算并标 `estimated: true`）；
`bus_register` / `bus_whoami` / `bus_list` 还会带 `handoff`（协作目录、入口文件、上次写入距今多少分钟）；
`bus_send` 另外带 `targetContext`——目标水位超过 50% 或已压缩过 1 次（`level` 不是 `ok`）还收到新指令时，会附一句「先让它归档」的警告。

`kind`：`指令`、`回报`、`通知`、`请求`（固定字面量，不要翻译）。

`bus_send` 的默认唤醒策略：`指令`/`通知` 立即唤醒；`回报` 进批投箱不唤醒；`请求` 立即唤醒。`wake` 显式传入时以它为准。

## 配置

配置读取顺序（后者覆盖前者）：

1. `index.js` 里的默认值（`DEFAULT_CONFIG`）
2. JSON 配置文件：`$DSH_HANDOFF_BUS_CONFIG`，或 `<DSH_HOME>/handoff-bus/config.json`
3. profile patch 行里的 `config`（高级）

配置文件示例：

```json
{
  "handoffDir": "handoff",
  "stateDirName": ".bus",
  "maxMessageChars": 4000,
  "maxSendsPerHour": 30,
  "maxPairSendsPer10Min": 8,
  "requireDone": true,
  "routeMaxAttempts": 3,
  "retiredRetentionDays": 7,
  "digestReports": true,
  "digestMinCount": 3,
  "digestMaxWaitSec": 900,
  "handoffAfterMessages": 40,
  "handoffAfterIdleMin": 30,
  "briefing": true,
  "registerHint": true,
  "briefingFiles": ["README.md", "PROTOCOL.md"],
  "briefingScan": [],
  "briefingScanLimit": 4,
  "briefingTailLines": 80,
  "contextBudget": true,
  "contextWindowTokens": 524288,
  "contextWarnRatio": 0.4,
  "contextStopRatio": 0.7,
  "contextOverheadTokens": 40000,
  "requireHandoverOnLeave": true,
  "handoverWindowMin": 30
}
```

`centralStateRoot` 可选，填绝对路径；默认 `<DSH_HOME>/handoff-bus`。改完重启客户端生效。示例见 `config.example.json`。

| 键 | 默认 | 说明 |
|---|---|---|
| `handoffDir` | `handoff` | 协作目录名；存在时状态放进 `<它>/.bus/` |
| `stateDirName` | `.bus` | 状态子目录名 |
| `centralStateRoot` | `<DSH_HOME>/handoff-bus` | 没有协作目录时用这里 |
| `maxMessageChars` | 4000 | 单条消息上限 |
| `maxSendsPerHour` | 30 | 每会话每小时上限 |
| `maxPairSendsPer10Min` | 8 | 同一对角色 10 分钟上限 |
| `requireDone` | `true` | `kind="指令"` 必须带 `done` |
| `routeMaxAttempts` | 3 | 线名路由最多试几个候选会话 |
| `retiredRetentionDays` | 7 | 已退役角色保留天数 |
| `digestReports` | `true` | 回报批投（关闭后每条回报都立即唤醒） |
| `digestMinCount` | 3 | 攒够几条回报合并唤醒一次 |
| `digestMaxWaitSec` | 900 | 最多压多久（秒） |
| `handoffAfterMessages` | 40 | 临时会话收发满多少条 → `suggestHandoff` |
| `handoffAfterIdleMin` | 30 | 临时会话空闲多少分钟 → `suggestHandoff` |
| `briefing` | `true` | 新会话首次请求前注入开工简报（仅当存在协作目录且有可读文件） |
| `registerHint` | `true` | 未注册会话首轮注入「注册 X」三步说明（含领号、读目录、回报）；与 `briefing` 无关，空目录也会发 |
| `briefingFiles` | `["README.md","PROTOCOL.md"]` | 全文读的文件，相对协作目录 |
| `briefingScan` | `[]` | 只读结构+末尾的文件 glob，例如 `["4*-*.md"]` |
| `briefingScanLimit` | 4 | 上面匹配到的文件里最多点名几份（按修改时间新的优先） |
| `briefingTailLines` | 80 | 上一条里 `tail -n` 的行数 |
| `contextBudget` | `true` | 上下文水位提醒 |
| `contextWindowTokens` | 524288 | 按多少 token 算 100%（不是 profile 里声明的 1048576） |
| `contextWarnRatio` | 0.4 | 到多少比例提醒「做完当前任务就停、归档换班」 |
| `contextStopRatio` | 0.7 | 到多少比例要求立即收尾（只用于没在总线上注册的会话） |
| `contextOverheadTokens` | 40000 | 系统提示 + 工具定义的固定开销估算 |
| `childWarnRatio` / `childStopRatio` | 0.5 / 0.7 | 临时会话的水位线：提示 / 停用 |
| `mainWarnRatio` / `mainDrainRatio` | 0.5 / 0.85 | 主对话的水位线：提示 / 进入收尾 |
| `compactionWarn` / `compactionStop` | 1 / 2 | 压缩到几次就提示 / 停用（优先于水位） |
| `contextProbeSec` | 60 | 测到的上下文复用多久 |
| `drainSweepSec` | 900 | 主对话收尾期间的自检间隔 |
| `drainSweepIdleRounds` | 3 | 几轮没动静后开始退避 |
| `drainSweepBackoffSec` | 3600 | 退避后的间隔 |
| `requireHandoverOnLeave` | `true` | 协作目录最近没被写过就拒绝 `bus_unregister` |
| `handoverWindowMin` | 30 | 上面那条的时间窗口（分钟） |

`deliveries.jsonl` 只追加；长期使用会变大，可手动删除，自动轮转在后续版本加。

## 错误码

| code | 含义 |
|---|---|
| `no_session` | 工具调用没有关联会话 |
| `no_workspace` | 会话没有 cwd |
| `invalid_role` / `invalid_target` / `invalid_message` / `invalid_kind` | 参数不合法 |
| `caller_not_registered` | 本会话还没注册角色 |
| `target_not_registered` | 目标角色/线在本工作区不存在 |
| `line_busy` | 这条线上有会话还欠着回报；等回报，或用 `preempt: true` |
| `no_available_session` | 这条线的会话全部停用（上下文满），或一个都没有 |
| `draining` | 本主对话在收尾：只收回报，不再派新指令 |
| `self_retired` | 本会话已停用（上下文满）：把状态写进台账，请用户归档 |
| `role_not_found` / `role_virtual` / `not_owner` | `bus_unregister`：角色不存在 / 是虚拟父节点 / 不属于当前会话 |
| `self_target` | 目标就是自己 |
| `target_unresolvable` | 目标会话无法打开（已归档/删除，或被 subagent 路由占用） |
| `rate_limited` | 触发限流 |
| `instruction_pending` | 仅 `bus_unregister`：这个角色还有指令没回报 |
| `instruction_incomplete` | `kind="指令"` 缺 `done`（完成标准） |
| `handover_missing` | 收工前没写协作目录；先写状态，或加 `force: true` |
| `flush_failed` | 已入队但持久化未确认 |
| `delivery_failed` | 投递异常 |
| `aborted` | 工具调用被取消 |

## 兼容性与稳定性

- 需要带 Host session controller 的 **web/desktop profile**；headless/仅 SDK 的组合可能没有该服务，插件会保持不激活（声明了 `inject`）。
- 使用宿主内部服务：`sessionController.resolveAgent`、`agent.followup/inject`、`sessions.flush`、`session.header.cwd`，以及原生 `ctx.tools.register`。这些不是公开稳定契约，DSH 升级可能需要适配。
- 开工简报与预算提醒另用了 `agent/created` 事件和 `agent/pre-step` 钩子（向本次请求追加一条 user 消息）。钩子返回异常时自动降级为「不注入」，不影响原有流程。
- **上下文水位来自宿主投影**：`sessionQuery.observeSession` 数压缩次数（事件流），`sessionProjectionCache.cachedSnapshot` 取 `contextTimeline` 的各桶之和与窗口；两者都是宿主内部服务，读不到时退回字符估算并标 `estimated: true`。`bus_list` 里的 `contextPct` 只对已经测量过的会话出现。
- **不能创建/关闭会话**：这两件事只能由人在 GUI 做。
- 仅 import Node 内置模块，无 npm 依赖。
- 工具结果与总线消息正文为中文；错误码是稳定的机器可读标识。
- `bindings.json` v1 会在读取时自动升级到 v2，无需手动迁移。

## 安全与信任模型

- 这是 **host 插件**，拥有宿主进程权限；请先读代码再用。
- 同一工作区内，任何会话都能 `bus_register` 任意角色（包括冒充 `main`）。只适合**同一 DSH profile、同一用户**的可信环境。
- 总线消息以 user 角色注入并唤醒目标；有限流与审计日志，但没有加密或签名。
- `deliveries.jsonl` 只记录元数据（谁、何时、给谁、消息 id、长度），不记录正文。
- 无网络请求、无遥测。

## 卸载与清理

- 插件页移除插件并重启。
- 状态文件不会自动删除：`<workspace>/handoff/.bus/` 或 `<DSH_HOME>/handoff-bus/`，可手动清理。

## 开发

```sh
npm test
```

测试用假 ctx / 假 agent 在临时目录里跑，不需要运行中的 DSH，也不碰真实状态。

设计约束：不要重新引入 `@deepseek-ai/*` import（外链安装会 `ERR_MODULE_NOT_FOUND`）；工具用原生 `ctx.tools.register`；消息在本地按 `createUserMessage` 等价结构构造。

仓库：<https://github.com/cwh-123456/dsh-handoff-bus>

## License

MIT，见 `LICENSE`。本项目是非官方插件，与 DeepSeek 无关。
