Skip to content

dsh-handoff-bus

Verified

dsh-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(推荐,发布后可用)

  1. DSH 左侧栏「插件」页 →「添加插件」→ 搜索 dsh-handoff-bus
  2. 安装后「立即启用」
  3. 完全退出客户端(含托盘)再打开(host 插件需要重启)

或者:

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" 落到同一个会话时共用同一套编号,不能靠换个写法绕过拦截。

配套的子对话提示词(推荐写进子线的开场指令里):

收到 [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/&lt;你的角色&gt;.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 条由插件替你记着(开工简报 + 预算提醒 + 收工写盘),不需要人盯。 按收益排序,能做的九件事:

  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-)只是可选的排序备注。

在这个目录建立 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 显式传入时以它为准。

配置

配置读取顺序(后者覆盖前者):

  1. index.js 里的默认值(DEFAULT_CONFIG)
  2. JSON 配置文件:$DSH_HANDOFF_BUS_CONFIG,或 <DSH_HOME>/handoff-bus/config.json
  3. 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.json v1 会在读取时自动升级到 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 无关。