dsh-handoff-bus
Verifieddsh-handoff-bus · v0.4.3 · MIT
Cross-session message bus for DeepSeek Harness GUI conversations: deliver instructions and reports directly between the main conversation and sub-conversations.
Install
dsh plugin add dsh-handoff-bus Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-handoff-bus — 会话总线
English | 中文
给 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(推荐,发布后可用)
- DSH 左侧栏「插件」页 →「添加插件」→ 搜索
dsh-handoff-bus - 安装后「立即启用」
- 完全退出客户端(含托盘)再打开(host 插件需要重启)
或者:
dsh plugin --profile <profile> add dsh-handoff-bus
CLI 命令针对 CLI 启动的 profile;桌面版的 profile 由客户端自己管理,请在插件页安装。
从源码
- clone 本仓库
- 插件页 →「添加插件」→ 填仓库目录的绝对路径
- 启用 → 完全退出客户端再打开
外链安装的插件位于 profile 的
node_modules之外,解析不到@deepseek-ai/*;本包只 import Node 内置模块,所以两种安装方式都能跑。
快速开始
- 主对话:
bus_register(role="main") - 每个子对话:
bus_register(parent="api")→ 自动拿到api.1 bus_list查看角色树与路由- 主 → 线:
bus_send(target="api", kind="指令", done="…", message="…") - 线 → 主:
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 说明「你说的是线名,我按领号处理了」。
注册成功后会告诉你排在哪:线上还有更小号的空闲会话,你就是替补,等它停用或归档才轮到你;线是空的,你就是直接上岗。这只是说明,不改变排队顺序——编号小的永远优先。
投递时怎么找目标
target="api.2"→ 精确发给它,不猜。target="api"→ 走这条线的队列:候选是「未停用」且「没有未完成指令」的会话,按编号升序,取编号最小的那个。逐个试着打开,最多试routeMaxAttempts个(默认 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"落到同一个会话时共用同一套编号,不能靠换个写法绕过拦截。
配套的子对话提示词(推荐写进子线的开场指令里):
收到 [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 才会有):
[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/<你的角色>.md(.md,不是 .txt;含未回报的指令 ref 与产物路径)。不收工不要注销,做完回报后待命即可。
预算提醒(40%):
[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 条由插件替你记着(开工简报 + 预算提醒 + 收工写盘),不需要人盯。 按收益排序,能做的九件事:
- 别让一条线跑到底(收益最大):上下文到了停用线就换人,别等它开始忘事、算错、反复重读同一个文件。但「任务做完就退」是浪费——做完回报后待命,下一条指令还能接着用同一个上下文;退早了,下一条指令就得新开对话、重新喂一遍历史和文件。
- 别往对话里贴大段日志:让它写文件、用 grep 定位,只把命中行贴回来。
done写清楚:省掉"再补一句"带来的整轮上下文。urgent只留给卡住/失败/要决策:其余回报走批投,不逐条叫醒。wake: false:不是「立刻开工」的消息就别叫醒,塞进收件箱,等它自己下次跑。kind="通知"做状态同步:纯落盘,不唤醒。- 不轮询:主对话每隔几分钟
bus_list问"到哪了",每次都是最贵的那份上下文,而且什么也没推进。 - 只发指针:正文给摘要 + 文件路径,正文内容留在文件里。
- 不用的 profile 直接禁用插件:那 1400 token 的 schema 就不带了。
新目录开工(发给主对话的提示词)
总线自己建目录。第一次 bus_register 就会创建 handoff/(以及里面的 .bus/ 状态子目录);如果这之前状态存在 DSH 用户目录里,会先搬过来,绑定和编号都保留。
新目录开工:
- 主对话
bus_register(role="main")—— 目录和状态一起就位。 - 写任务/指令文件,用
bus_send派活。 - 子对话那边只要说一句「注册 api」,它就会拿到编号、读到目录、回报已接手。
文件约定:状态写 handoff/<角色>.md(例如 handoff/api.2.md),用 .md,不要 .txt——.txt 不会被开工简报和收工检查认。
下面骨架里的文件名只是示例,可自由改名;数字前缀(如 40-)只是可选的排序备注。
在这个目录建立 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 给我看角色表。
子对话(每开一个新的就发这一段):
你是 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 显式传入时以它为准。
配置
配置读取顺序(后者覆盖前者):
index.js里的默认值(DEFAULT_CONFIG)- JSON 配置文件:
$DSH_HANDOFF_BUS_CONFIG,或<DSH_HOME>/handoff-bus/config.json - profile patch 行里的
config(高级)
配置文件示例:
{
"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.jsonv1 会在读取时自动升级到 v2,无需手动迁移。
安全与信任模型
- 这是 host 插件,拥有宿主进程权限;请先读代码再用。
- 同一工作区内,任何会话都能
bus_register任意角色(包括冒充main)。只适合同一 DSH profile、同一用户的可信环境。 - 总线消息以 user 角色注入并唤醒目标;有限流与审计日志,但没有加密或签名。
deliveries.jsonl只记录元数据(谁、何时、给谁、消息 id、长度),不记录正文。- 无网络请求、无遥测。
卸载与清理
- 插件页移除插件并重启。
- 状态文件不会自动删除:
<workspace>/handoff/.bus/或<DSH_HOME>/handoff-bus/,可手动清理。
开发
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 无关。