dsh-handoff-bus 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +61 -4
- package/README.zh.md +50 -4
- package/config.example.json +3 -1
- package/index.js +141 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Fixed — instructions piling up
|
|
6
|
+
|
|
7
|
+
Each delivery becomes its own turn in the target conversation, so a burst of `指令`
|
|
8
|
+
made the target execute obsolete commands one after another while the task file had
|
|
9
|
+
already moved on. Two changes:
|
|
10
|
+
|
|
11
|
+
- **Instruction back-pressure**: a second `指令` to the same target is refused with
|
|
12
|
+
`instruction_pending` while the previous one has no `回报` yet. Update the task file
|
|
13
|
+
and wait for the report, or pass `force: true`. Bounded by `pendingInstructionTtlSec`
|
|
14
|
+
(default 600s), so a target that never reports does not block forever. Notices and
|
|
15
|
+
requests are not affected.
|
|
16
|
+
- **Self-identifying messages**: every `指令` footer now carries its per-pair sequence
|
|
17
|
+
("本线第 N 条指令") and tells the receiver to compare it with `bus_list.latest`; a
|
|
18
|
+
message whose `seq` is below the latest is stale and should not be executed.
|
|
19
|
+
|
|
20
|
+
`bus_list` additionally returns `latest`: the newest instruction per role pair, with
|
|
21
|
+
`seq`, `ref`, and `awaitingReport`. Both the counter and the pending state are rebuilt
|
|
22
|
+
from `deliveries.jsonl`, so they survive a client restart without another state file.
|
|
23
|
+
|
|
24
|
+
New config keys: `blockSecondInstruction` (default `true`), `pendingInstructionTtlSec`
|
|
25
|
+
(default `600`). New `bus_send` parameter: `force`.
|
|
26
|
+
|
|
27
|
+
### Fixed — documentation
|
|
28
|
+
|
|
29
|
+
- `target_unresolvable` now says the target may also be "owned by subagent routing", and states that only a top-level GUI conversation can be a target.
|
|
30
|
+
- `bus_send` tool description documents the subagent restriction and that `ref` is passed through verbatim.
|
|
31
|
+
- The protocol line appended to `指令` messages now suggests `R-` numbering (e.g. `R-001`) for reports.
|
|
32
|
+
- README (both languages): documented the subagent restriction, the `ref` convention, the instruction back-pressure, and a token-budget section.
|
|
33
|
+
|
|
3
34
|
## 0.1.0 — 2026-10-18
|
|
4
35
|
|
|
5
36
|
- Initial public release.
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A small host plugin for [DeepSeek Harness](https://www.deepseek.com/harness/) (D
|
|
|
16
16
|
- ❌ No file watching, no timers, no autonomous work when you are away
|
|
17
17
|
- ❌ It does not replace your state files: task files and reports are still written by the conversations; the bus only delivers pointers and wakes the target
|
|
18
18
|
- ❌ No cross-workspace addressing (one bus per workspace)
|
|
19
|
+
- ❌ No addressing of subagent sessions: only a **top-level GUI conversation** can be a target. A session running as a subagent under another agent is owned by the host router; registering it from inside does not make it reachable, and `bus_send` answers `target_unresolvable`
|
|
19
20
|
|
|
20
21
|
## How it works
|
|
21
22
|
|
|
@@ -75,6 +76,8 @@ dsh plugin --profile <profile> add dsh-handoff-bus
|
|
|
75
76
|
|
|
76
77
|
Role names are arbitrary strings you choose; `worker-a` / `worker-b` are only examples.
|
|
77
78
|
|
|
79
|
+
`ref` is passed through verbatim — nothing validates its format. The convention in the examples is `D-` for instructions and `R-` for reports (`Q-` for status requests), numbered per project. Pick one scheme and keep it consistent; it only lands in the message header, so the receiving conversation is what gives it meaning.
|
|
80
|
+
|
|
78
81
|
| Situation | Call |
|
|
79
82
|
|---|---|
|
|
80
83
|
| Main gives work to a sub | `bus_send(target="worker-a", kind="指令", ref="D-001", message="…")` |
|
|
@@ -82,6 +85,55 @@ Role names are arbitrary strings you choose; `worker-a` / `worker-b` are only ex
|
|
|
82
85
|
| Main asks for status (no work) | `bus_send(target="worker-a", kind="请求", ref="Q-…", message="Status only, do not start work.")` |
|
|
83
86
|
| Queue without waking | add `"wake": false` |
|
|
84
87
|
|
|
88
|
+
## Delivering without piling up
|
|
89
|
+
|
|
90
|
+
Every delivery is a separate turn in the target conversation. If `main` sends three
|
|
91
|
+
`指令` to `worker-a` in a row while `worker-a` is still working, all three queue up and
|
|
92
|
+
are executed in order — the first one runs against a task file that is already at
|
|
93
|
+
version three. Three consequences:
|
|
94
|
+
|
|
95
|
+
- **A second `指令` is refused** while the previous one has no `回报`
|
|
96
|
+
(`instruction_pending`). Update the task file, wait for the report, then dispatch
|
|
97
|
+
again; `force: true` overrides when you really mean to queue.
|
|
98
|
+
- **Each `指令` carries its own sequence** ("本线第 3 条指令") and the footer tells the
|
|
99
|
+
receiver to compare it with `bus_list.latest`. A `seq` below the latest means the
|
|
100
|
+
message was superseded: do the newest one, skip the duplicate work.
|
|
101
|
+
- **The counter and the pending state are rebuilt from `deliveries.jsonl`**, so a client
|
|
102
|
+
restart neither resets the numbering nor forgets an unanswered instruction.
|
|
103
|
+
|
|
104
|
+
A sub-conversation prompt that makes this reliable:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
收到 [BUS 指令] 后,先调用 bus_list 看 latest 里「main->自己角色」的 seq。
|
|
108
|
+
如果消息末尾写的「本线第 N 条」小于 seq,说明已被后面的指令取代:
|
|
109
|
+
只做最新那条,不要重复劳动,并在回报里说明跳过了哪一条。
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Token cost
|
|
113
|
+
|
|
114
|
+
The bus itself is cheap; the turns it triggers are not.
|
|
115
|
+
|
|
116
|
+
| Item | Cost |
|
|
117
|
+
|---|---|
|
|
118
|
+
| Tool schemas (4 tools) | ~700 tokens per request, in every conversation, used or not |
|
|
119
|
+
| Message wrapper | ~120 tokens per delivered message (header, workspace, time, protocol lines) |
|
|
120
|
+
| **A woken turn** | **the target's whole context, re-sent as input** |
|
|
121
|
+
|
|
122
|
+
The third row dominates. Waking a conversation that already holds 30k tokens of history
|
|
123
|
+
costs 30k input tokens for a 20-character message — and the message stays in that
|
|
124
|
+
history, so every later turn of that session is slightly more expensive as well.
|
|
125
|
+
|
|
126
|
+
Ways to spend less:
|
|
127
|
+
|
|
128
|
+
1. **Batch.** Update the task file, then send one `指令`, not four.
|
|
129
|
+
2. **`wake: false`** for anything that is not a "start working now" signal: it lands in
|
|
130
|
+
the inbox and costs nothing until the target runs on its own.
|
|
131
|
+
3. **Keep sub-conversation sessions short.** Wake cost = that session's context length,
|
|
132
|
+
so a line that has been running for hours is the expensive one. Finish a milestone,
|
|
133
|
+
open a fresh conversation, `bus_register` again.
|
|
134
|
+
4. **Pointers, not payloads.** Summary + file path in the body; the report stays in the file.
|
|
135
|
+
5. **Disable the plugin in profiles that never use it** — that removes the ~700-token schema.
|
|
136
|
+
|
|
85
137
|
## New workspace setup (copy-paste prompt)
|
|
86
138
|
|
|
87
139
|
The bus does not create directories or write task files. In a new workspace:
|
|
@@ -142,8 +194,8 @@ bus_send(target="main", kind="回报", ref="R-<date>-01", message="Report writte
|
|
|
142
194
|
|---|---|
|
|
143
195
|
| `bus_register(role, note?)` | Register this conversation as a role **in its workspace** |
|
|
144
196
|
| `bus_whoami()` | Current role + workspace |
|
|
145
|
-
| `bus_list()` | All roles **in this workspace** → sessionId / bound time |
|
|
146
|
-
| `bus_send(target, message, kind?, ref?, wake?)` | Deliver to a role in this workspace; `wake=true` wakes it |
|
|
197
|
+
| `bus_list()` | All roles **in this workspace** → sessionId / bound time, plus `latest` (newest `指令` per pair with `seq` / `awaitingReport`) |
|
|
198
|
+
| `bus_send(target, message, kind?, ref?, wake?, force?)` | Deliver to a role in this workspace; `wake=true` wakes it, `force=true` overrides instruction back-pressure |
|
|
147
199
|
|
|
148
200
|
`kind`: `指令` (instruction), `回报` (report), `通知` (notice), `请求` (request). These four values are literal tokens — keep them in Chinese; do not translate them.
|
|
149
201
|
|
|
@@ -163,7 +215,9 @@ Example `config.json`:
|
|
|
163
215
|
"stateDirName": ".bus",
|
|
164
216
|
"maxMessageChars": 4000,
|
|
165
217
|
"maxSendsPerHour": 30,
|
|
166
|
-
"maxPairSendsPer10Min": 8
|
|
218
|
+
"maxPairSendsPer10Min": 8,
|
|
219
|
+
"blockSecondInstruction": true,
|
|
220
|
+
"pendingInstructionTtlSec": 600
|
|
167
221
|
}
|
|
168
222
|
```
|
|
169
223
|
|
|
@@ -177,6 +231,8 @@ Example `config.json`:
|
|
|
177
231
|
| `maxMessageChars` | 4000 | Per-message limit |
|
|
178
232
|
| `maxSendsPerHour` | 30 | Per-conversation hourly limit |
|
|
179
233
|
| `maxPairSendsPer10Min` | 8 | Per role-pair limit, to stop ping-pong |
|
|
234
|
+
| `blockSecondInstruction` | `true` | Refuse a `指令` while the previous one to the same target has no `回报` yet |
|
|
235
|
+
| `pendingInstructionTtlSec` | 600 | After this long without a `回报`, the pending instruction stops blocking |
|
|
180
236
|
|
|
181
237
|
`deliveries.jsonl` is append-only. Delete it manually if it grows; automatic rotation is planned for a later release.
|
|
182
238
|
|
|
@@ -190,8 +246,9 @@ Example `config.json`:
|
|
|
190
246
|
| `caller_not_registered` | This conversation has no role yet |
|
|
191
247
|
| `target_not_registered` | The target role does not exist in this workspace |
|
|
192
248
|
| `self_target` | The target is this conversation |
|
|
193
|
-
| `target_unresolvable` | The target session cannot be opened (archived/deleted) |
|
|
249
|
+
| `target_unresolvable` | The target session cannot be opened (archived/deleted, or owned by subagent routing) |
|
|
194
250
|
| `rate_limited` | Rate limit hit |
|
|
251
|
+
| `instruction_pending` | A `指令` to this target is still unanswered; wait for the `回报`, or pass `force: true` |
|
|
195
252
|
| `flush_failed` | Queued but persistence was not acknowledged |
|
|
196
253
|
| `delivery_failed` | Delivery error |
|
|
197
254
|
| `aborted` | The tool call was cancelled |
|
package/README.zh.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
- ❌ 不监听文件、不设定时任务、不在你不在场时自动开工
|
|
17
17
|
- ❌ 不替代你的状态文件:任务文件、回报仍由对话自己写,总线只负责送达和叫醒
|
|
18
18
|
- ❌ 不跨工作区寻址(每个目录一条总线)
|
|
19
|
+
- ❌ 不能向子代理会话发送消息:目标必须是**顶层 GUI 会话**。作为 subagent 跑在别的会话下面的会话由宿主路由接管,在里面注册也发不进去,`bus_send` 会返回 `target_unresolvable`
|
|
19
20
|
|
|
20
21
|
## 工作流
|
|
21
22
|
|
|
@@ -68,6 +69,8 @@ dsh plugin --profile <profile> add dsh-handoff-bus
|
|
|
68
69
|
|
|
69
70
|
角色名是任意字符串,`worker-a` / `worker-b` 只是示例。
|
|
70
71
|
|
|
72
|
+
`ref` 原样透传,工具不校验格式。示例里的约定是:指令用 `D-`、回报用 `R-`、状态询问用 `Q-`,后面跟项目内的编号。你选一套保持前后一致即可 —— 它只会出现在消息头里,含义由接收方按约定理解。
|
|
73
|
+
|
|
71
74
|
| 用途 | 调用 |
|
|
72
75
|
|---|---|
|
|
73
76
|
| 主 → 子派活 | `bus_send(target="worker-a", kind="指令", ref="D-001", message="…")` |
|
|
@@ -75,6 +78,44 @@ dsh plugin --profile <profile> add dsh-handoff-bus
|
|
|
75
78
|
| 主问进度(不派活) | `bus_send(target="worker-a", kind="请求", ref="Q-…", message="只回当前状态,不要开工")` |
|
|
76
79
|
| 只想排队、不叫醒 | 加 `"wake": false` |
|
|
77
80
|
|
|
81
|
+
## 防止指令堆积
|
|
82
|
+
|
|
83
|
+
每一次投递,都会在目标会话里单独变成一个 turn。主对话在子线还在干活时连发三条指令,
|
|
84
|
+
三条都会排队,然后按顺序依次执行 —— 第一条跑的时候,任务文件早就到第三版了。所以:
|
|
85
|
+
|
|
86
|
+
- **上一条指令没回报时,第二条会被拒绝**(`instruction_pending`)。正确做法是更新任务文件、等回报、再下发;确实要排队就加 `force: true`。
|
|
87
|
+
- **每条指令自带序号**(消息末尾的「本线第 3 条指令」),并提示接收方用 `bus_list` 的 `latest` 对照。序号小于最新的,说明已被取代:只做最新那条,不要重复劳动。
|
|
88
|
+
- **计数和「待回报」状态都从 `deliveries.jsonl` 重放**,所以重启客户端既不会把编号清零,也不会忘掉一条还没回报的指令。
|
|
89
|
+
|
|
90
|
+
配套的子对话提示词(推荐写进子线的开场指令里):
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
收到 [BUS 指令] 后,先调用 bus_list 看 latest 里「main->自己角色」的 seq。
|
|
94
|
+
如果消息末尾写的「本线第 N 条」小于 seq,说明已被后面的指令取代:
|
|
95
|
+
只做最新那条,不要重复劳动,并在回报里说明跳过了哪一条。
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Token 开销
|
|
99
|
+
|
|
100
|
+
总线本身很便宜,它触发的 turn 才是花钱的地方。
|
|
101
|
+
|
|
102
|
+
| 项目 | 成本 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| 四个工具的定义 | **每次请求**约 700 token,所有会话都带,用不用都算 |
|
|
105
|
+
| 消息包装 | 每条约 120 token(消息头、工作区、时间、协议句) |
|
|
106
|
+
| **被唤醒的一个 turn** | **目标会话的整个上下文重新作为输入送一遍** |
|
|
107
|
+
|
|
108
|
+
真正贵的是第三行。一个已经积累 3 万 token 的会话,被叫醒来读一条 20 字的消息,这次唤醒就要
|
|
109
|
+
3 万输入 token;而且这条消息会永久留在它的历史里,这个会话之后每一轮都会略贵一点。
|
|
110
|
+
|
|
111
|
+
省 token 的五个做法:
|
|
112
|
+
|
|
113
|
+
1. **攒批**:先更新任务文件,再发**一条**指令,而不是连发四条。
|
|
114
|
+
2. **`wake: false`**:不是「立刻开工」的消息就别叫醒,塞进收件箱,等它自己下次跑。
|
|
115
|
+
3. **子线会话别开太久**:唤醒成本 = 该会话的上下文长度,跑了几小时的那条线就是最贵的。做完一个阶段就新开一个会话,重新 `bus_register`。
|
|
116
|
+
4. **只发指针**:正文给摘要 + 文件路径,正文内容留在文件里。
|
|
117
|
+
5. **不用的 profile 直接禁用插件**:那 700 token 的 schema 就不带了。
|
|
118
|
+
|
|
78
119
|
## 新目录开工(发给主对话的提示词)
|
|
79
120
|
|
|
80
121
|
总线不建目录、不写任务文件。新目录第一次用时:
|
|
@@ -132,8 +173,8 @@ bus_send(target="main", kind="回报", ref="R-<日期>-01", message="回报已
|
|
|
132
173
|
|---|---|
|
|
133
174
|
| `bus_register(role, note?)` | 把当前会话注册为**本工作区**的角色 |
|
|
134
175
|
| `bus_whoami()` | 当前会话的角色 + 工作区 |
|
|
135
|
-
| `bus_list()` | 列出**本工作区**所有角色 → sessionId /
|
|
136
|
-
| `bus_send(target, message, kind?, ref?, wake?)` | 投递到本工作区目标;`wake=true`
|
|
176
|
+
| `bus_list()` | 列出**本工作区**所有角色 → sessionId / 绑定时间,并附 `latest`(每对角色最新一条指令的 `seq` 与是否仍未回报) |
|
|
177
|
+
| `bus_send(target, message, kind?, ref?, wake?, force?)` | 投递到本工作区目标;`wake=true` 默认唤醒,`force=true` 跳过指令堆积拦截 |
|
|
137
178
|
|
|
138
179
|
`kind`:`指令`、`回报`、`通知`、`请求`(固定字面量,不要翻译)。
|
|
139
180
|
|
|
@@ -153,7 +194,9 @@ bus_send(target="main", kind="回报", ref="R-<日期>-01", message="回报已
|
|
|
153
194
|
"stateDirName": ".bus",
|
|
154
195
|
"maxMessageChars": 4000,
|
|
155
196
|
"maxSendsPerHour": 30,
|
|
156
|
-
"maxPairSendsPer10Min": 8
|
|
197
|
+
"maxPairSendsPer10Min": 8,
|
|
198
|
+
"blockSecondInstruction": true,
|
|
199
|
+
"pendingInstructionTtlSec": 600
|
|
157
200
|
}
|
|
158
201
|
```
|
|
159
202
|
|
|
@@ -167,6 +210,8 @@ bus_send(target="main", kind="回报", ref="R-<日期>-01", message="回报已
|
|
|
167
210
|
| `maxMessageChars` | 4000 | 单条消息上限 |
|
|
168
211
|
| `maxSendsPerHour` | 30 | 每会话每小时上限 |
|
|
169
212
|
| `maxPairSendsPer10Min` | 8 | 同一对角色 10 分钟上限 |
|
|
213
|
+
| `blockSecondInstruction` | `true` | 上一条指令还没回报时,拒绝对同一目标再下指令 |
|
|
214
|
+
| `pendingInstructionTtlSec` | 600 | 超过这个时间没回报,就不再拦 |
|
|
170
215
|
|
|
171
216
|
`deliveries.jsonl` 只追加;长期使用会变大,可手动删除,自动轮转在后续版本加。
|
|
172
217
|
|
|
@@ -180,8 +225,9 @@ bus_send(target="main", kind="回报", ref="R-<日期>-01", message="回报已
|
|
|
180
225
|
| `caller_not_registered` | 本会话还没注册角色 |
|
|
181
226
|
| `target_not_registered` | 目标角色在本工作区不存在 |
|
|
182
227
|
| `self_target` | 目标就是自己 |
|
|
183
|
-
| `target_unresolvable` |
|
|
228
|
+
| `target_unresolvable` | 目标会话无法打开(已归档/删除,或被 subagent 路由占用) |
|
|
184
229
|
| `rate_limited` | 触发限流 |
|
|
230
|
+
| `instruction_pending` | 上一条指令还没回报;等回报,或加 `force: true` |
|
|
185
231
|
| `flush_failed` | 已入队但持久化未确认 |
|
|
186
232
|
| `delivery_failed` | 投递异常 |
|
|
187
233
|
| `aborted` | 工具调用被取消 |
|
package/config.example.json
CHANGED
package/index.js
CHANGED
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
* - Delivery resolves the target Session through the Host session controller
|
|
30
30
|
* and appends a user-role message to its inbox; `wake: true` (default)
|
|
31
31
|
* also wakes it.
|
|
32
|
+
* - Instructions are back-pressured: each delivery becomes its own turn in the
|
|
33
|
+
* target, so a queued 指令 is still executed after a newer one arrives. A second
|
|
34
|
+
* 指令 is therefore refused while the previous one has no 回报 (`instruction_pending`),
|
|
35
|
+
* unless the caller passes `force: true`. Every 指令 also carries its per-pair
|
|
36
|
+
* sequence, so a superseded message can identify itself via `bus_list.latest`.
|
|
32
37
|
*
|
|
33
38
|
* The delivery path mirrors the shipped `dsh-schedule` package:
|
|
34
39
|
* ctx.sessionController.resolveAgent(sessionId) -> agent.followup(message)
|
|
@@ -54,6 +59,15 @@ const DEFAULT_CONFIG = {
|
|
|
54
59
|
maxMessageChars: 4000,
|
|
55
60
|
maxSendsPerHour: 30,
|
|
56
61
|
maxPairSendsPer10Min: 8,
|
|
62
|
+
/**
|
|
63
|
+
* Refuse a second 指令 to the same target while the previous one has not been
|
|
64
|
+
* answered with a 回报. Each delivery becomes its own turn, so a burst of
|
|
65
|
+
* instructions makes the target execute obsolete commands one by one.
|
|
66
|
+
* Override per call with `force: true`.
|
|
67
|
+
*/
|
|
68
|
+
blockSecondInstruction: true,
|
|
69
|
+
/** After this long without a 回报, the pending instruction stops blocking. */
|
|
70
|
+
pendingInstructionTtlSec: 600,
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
/** Standard JSON Schema shared by all four tools; every result carries `ok`. */
|
|
@@ -74,6 +88,9 @@ const OUTPUT_SCHEMA = {
|
|
|
74
88
|
sentAt: { type: 'string' },
|
|
75
89
|
woken: { type: 'boolean' },
|
|
76
90
|
replaced: { type: 'string' },
|
|
91
|
+
warning: { type: 'string' },
|
|
92
|
+
seq: { type: 'number' },
|
|
93
|
+
latest: { type: 'object' },
|
|
77
94
|
roles: {
|
|
78
95
|
type: 'array',
|
|
79
96
|
items: {
|
|
@@ -200,16 +217,54 @@ export function apply(ctx, config) {
|
|
|
200
217
|
return { version: 1, roles: {} }
|
|
201
218
|
}
|
|
202
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Delivery log is metadata-only and append-only; replaying it at startup is
|
|
222
|
+
* how the per-pair instruction counter and the "still awaiting a 回报" state
|
|
223
|
+
* survive a client restart without another state file.
|
|
224
|
+
*/
|
|
225
|
+
function replayDeliveries(file) {
|
|
226
|
+
const state = { seq: new Map(), lastInstruction: new Map(), lastReport: new Map() }
|
|
227
|
+
let raw
|
|
228
|
+
try {
|
|
229
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
230
|
+
} catch {
|
|
231
|
+
return state
|
|
232
|
+
}
|
|
233
|
+
for (const line of raw.split('\n')) {
|
|
234
|
+
if (line.trim().length === 0) continue
|
|
235
|
+
let entry
|
|
236
|
+
try {
|
|
237
|
+
entry = JSON.parse(line)
|
|
238
|
+
} catch {
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (!entry || typeof entry.fromRole !== 'string' || typeof entry.toRole !== 'string') continue
|
|
242
|
+
if (typeof entry.at !== 'string') continue
|
|
243
|
+
const pair = `${entry.fromRole}->${entry.toRole}`
|
|
244
|
+
if (entry.kind === '指令') {
|
|
245
|
+
const next = (state.seq.get(pair) ?? 0) + 1
|
|
246
|
+
state.seq.set(pair, next)
|
|
247
|
+
state.lastInstruction.set(pair, { seq: next, at: entry.at, ref: entry.ref })
|
|
248
|
+
} else if (entry.kind === '回报') {
|
|
249
|
+
// A report from B to A acknowledges A's last instruction to B.
|
|
250
|
+
state.lastReport.set(`${entry.toRole}->${entry.fromRole}`, entry.at)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return state
|
|
254
|
+
}
|
|
255
|
+
|
|
203
256
|
function busFor(workspace) {
|
|
204
257
|
const existing = buses.get(workspace)
|
|
205
258
|
if (existing) return existing
|
|
206
259
|
const dir = stateDirFor(workspace)
|
|
260
|
+
const deliveriesPath = path.join(dir, 'deliveries.jsonl')
|
|
207
261
|
const bus = {
|
|
208
262
|
workspace,
|
|
209
263
|
dir,
|
|
210
264
|
bindingsPath: path.join(dir, 'bindings.json'),
|
|
211
|
-
deliveriesPath
|
|
265
|
+
deliveriesPath,
|
|
212
266
|
bindings: readBindings(path.join(dir, 'bindings.json')),
|
|
267
|
+
state: replayDeliveries(deliveriesPath),
|
|
213
268
|
sendTimes: new Map(), // callerSessionId -> number[]
|
|
214
269
|
pairTimes: new Map(), // "fromRole->toRole" -> number[]
|
|
215
270
|
}
|
|
@@ -217,6 +272,17 @@ export function apply(ctx, config) {
|
|
|
217
272
|
return bus
|
|
218
273
|
}
|
|
219
274
|
|
|
275
|
+
/** Instruction that was sent to `toRole` and never acknowledged by a 回报. */
|
|
276
|
+
function pendingInstruction(bus, fromRole, toRole) {
|
|
277
|
+
const pair = `${fromRole}->${toRole}`
|
|
278
|
+
const last = bus.state.lastInstruction.get(pair)
|
|
279
|
+
if (!last) return undefined
|
|
280
|
+
const ackedAt = bus.state.lastReport.get(pair)
|
|
281
|
+
if (ackedAt && Date.parse(ackedAt) >= Date.parse(last.at)) return undefined
|
|
282
|
+
if (Date.now() - Date.parse(last.at) > cfg.pendingInstructionTtlSec * 1000) return undefined
|
|
283
|
+
return last
|
|
284
|
+
}
|
|
285
|
+
|
|
220
286
|
// ---------------------------------------------------------------- bindings
|
|
221
287
|
|
|
222
288
|
function writeBindings(bus) {
|
|
@@ -254,6 +320,24 @@ export function apply(ctx, config) {
|
|
|
254
320
|
.sort((a, b) => a.role.localeCompare(b.role))
|
|
255
321
|
}
|
|
256
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Latest instruction per role pair. A woken conversation compares the `seq`
|
|
325
|
+
* in its message footer against this to see whether it was superseded.
|
|
326
|
+
*/
|
|
327
|
+
function latestInstructions(bus) {
|
|
328
|
+
const out = {}
|
|
329
|
+
for (const [pair, info] of bus.state.lastInstruction) {
|
|
330
|
+
const [fromRole, toRole] = pair.split('->')
|
|
331
|
+
out[pair] = {
|
|
332
|
+
seq: info.seq,
|
|
333
|
+
...(info.ref ? { ref: info.ref } : {}),
|
|
334
|
+
at: info.at,
|
|
335
|
+
awaitingReport: pendingInstruction(bus, fromRole, toRole) !== undefined,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
|
|
257
341
|
// ---------------------------------------------------------------- delivery
|
|
258
342
|
|
|
259
343
|
function appendDelivery(bus, entry) {
|
|
@@ -282,7 +366,7 @@ export function apply(ctx, config) {
|
|
|
282
366
|
return { ok: true }
|
|
283
367
|
}
|
|
284
368
|
|
|
285
|
-
function composeBusText({ workspace, fromRole, toRole, kind, ref, message }) {
|
|
369
|
+
function composeBusText({ workspace, fromRole, toRole, kind, ref, message, seq }) {
|
|
286
370
|
const lines = [
|
|
287
371
|
`[BUS ${kind}] ${fromRole} → ${toRole}${ref ? ` · ${ref}` : ''}`,
|
|
288
372
|
`工作区:${workspace}`,
|
|
@@ -294,7 +378,11 @@ export function apply(ctx, config) {
|
|
|
294
378
|
'权威状态仍以 handoff/ 对应文件为准;本条只负责送达和提醒。',
|
|
295
379
|
]
|
|
296
380
|
if (kind === '指令') {
|
|
297
|
-
lines.push(
|
|
381
|
+
lines.push(
|
|
382
|
+
`本线第 ${seq} 条指令。动手前先用 bus_list 核对本线最新编号:若最新编号大于 ${seq},` +
|
|
383
|
+
`说明后面还有新指令,本条按过期处理,只做最新那条,不要重复劳动。` +
|
|
384
|
+
`完成后 bus_send 回报「${fromRole}」(kind=回报,ref 用 R- 编号,如 R-001);不要自动回执,也不要转发给其他线。`,
|
|
385
|
+
)
|
|
298
386
|
} else if (kind === '回报') {
|
|
299
387
|
lines.push('收到回报后按协议更新你们的状态文件;需要继续时再用 bus_send 下达指令。')
|
|
300
388
|
} else {
|
|
@@ -387,7 +475,9 @@ export function apply(ctx, config) {
|
|
|
387
475
|
name: 'bus_list',
|
|
388
476
|
description:
|
|
389
477
|
'List every role currently bound in this conversation\'s workspace, with its sessionId and binding time. ' +
|
|
390
|
-
'Use it to check which target names bus_send accepts. Other workspaces have their own independent tables.'
|
|
478
|
+
'Use it to check which target names bus_send accepts. Other workspaces have their own independent tables. ' +
|
|
479
|
+
'Also returns `latest`: the newest 指令 per role pair (with its seq and whether a 回报 is still outstanding), ' +
|
|
480
|
+
'so a woken conversation can tell whether the message in front of it has already been superseded.',
|
|
391
481
|
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
|
392
482
|
async execute(_args, exec) {
|
|
393
483
|
const sessionId = exec?.agent?.session?.id
|
|
@@ -400,6 +490,7 @@ export function apply(ctx, config) {
|
|
|
400
490
|
workspace,
|
|
401
491
|
caller: roleOfSession(bus, sessionId) ?? '',
|
|
402
492
|
roles: listRoles(bus),
|
|
493
|
+
latest: latestInstructions(bus),
|
|
403
494
|
}
|
|
404
495
|
},
|
|
405
496
|
})
|
|
@@ -412,8 +503,12 @@ export function apply(ctx, config) {
|
|
|
412
503
|
'a sub-conversation uses it to report back (target = main). ' +
|
|
413
504
|
'The target sees the message as a user message starting with "[BUS ...]". ' +
|
|
414
505
|
'Roles are scoped to the workspace; a role registered in another directory is not addressable from here. ' +
|
|
506
|
+
'The target must be a top-level GUI conversation the host can reopen; a session running as a subagent under another agent cannot be a target. ' +
|
|
415
507
|
'Put only the summary and the handoff file pointers in the body; write authoritative state to the files first. ' +
|
|
416
|
-
'Use ref for the instruction/report id (e.g. D-001 / R-001). ' +
|
|
508
|
+
'Use ref for the instruction/report id (e.g. D-001 / R-001); it is passed through verbatim and not validated. ' +
|
|
509
|
+
'A second 指令 to the same target is refused while the previous one has not been answered with a 回报 ' +
|
|
510
|
+
'(each delivery costs the target a whole turn, so a burst makes it execute obsolete instructions). ' +
|
|
511
|
+
'Update the task file and wait for the report instead; pass force=true to override. ' +
|
|
417
512
|
'Set wake=false to queue the message without starting a turn.',
|
|
418
513
|
parameters: {
|
|
419
514
|
type: 'object',
|
|
@@ -439,6 +534,10 @@ export function apply(ctx, config) {
|
|
|
439
534
|
type: 'boolean',
|
|
440
535
|
description: 'Wake the target into a new turn (default true). false = queue only.',
|
|
441
536
|
},
|
|
537
|
+
force: {
|
|
538
|
+
type: 'boolean',
|
|
539
|
+
description: 'Send a 指令 even though the previous one has not been answered with a 回报 yet.',
|
|
540
|
+
},
|
|
442
541
|
},
|
|
443
542
|
required: ['target', 'message'],
|
|
444
543
|
},
|
|
@@ -478,6 +577,22 @@ export function apply(ctx, config) {
|
|
|
478
577
|
}
|
|
479
578
|
const ref = nonEmptyString(args.ref) ? args.ref.trim() : ''
|
|
480
579
|
const wantWake = args.wake !== false
|
|
580
|
+
const pairKey = `${callerRole}->${targetRole}`
|
|
581
|
+
|
|
582
|
+
// Back-pressure on instructions: a queued instruction is executed even
|
|
583
|
+
// after a newer one arrives, so refuse the second one instead of letting
|
|
584
|
+
// the target burn a turn on an obsolete task.
|
|
585
|
+
const blocked = kind === '指令' ? pendingInstruction(bus, callerRole, targetRole) : undefined
|
|
586
|
+
if (blocked && cfg.blockSecondInstruction && args.force !== true) {
|
|
587
|
+
const at = new Date(blocked.at).toLocaleString('sv-SE')
|
|
588
|
+
return fail(
|
|
589
|
+
'instruction_pending',
|
|
590
|
+
`「${targetRole}」还没回报上一条指令${blocked.ref ? `(${blocked.ref},${at} 发出)` : `(${at} 发出)`},` +
|
|
591
|
+
'很可能仍在执行。再发一条不会替换它,只会排到它后面 —— 目标会先把旧指令跑完。' +
|
|
592
|
+
'建议:先更新任务/状态文件,等它回报后再下发;确实要插队就加 force: true。',
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
const seq = (bus.state.seq.get(pairKey) ?? 0) + 1
|
|
481
596
|
|
|
482
597
|
let resolved
|
|
483
598
|
try {
|
|
@@ -488,7 +603,9 @@ export function apply(ctx, config) {
|
|
|
488
603
|
if (!resolved || 'error' in resolved) {
|
|
489
604
|
return fail(
|
|
490
605
|
'target_unresolvable',
|
|
491
|
-
|
|
606
|
+
`无法打开目标会话(可能已归档/删除,或被 subagent 路由占用):` +
|
|
607
|
+
`${resolved?.error?.message ?? String(resolved?.error ?? 'unknown')}。` +
|
|
608
|
+
'总线目标必须是宿主能直接打开的顶层 GUI 会话;作为子代理(subagent)运行的会话不能作为目标。',
|
|
492
609
|
)
|
|
493
610
|
}
|
|
494
611
|
|
|
@@ -497,7 +614,7 @@ export function apply(ctx, config) {
|
|
|
497
614
|
return fail('rate_limited', `发送过于频繁,约 ${Math.ceil(slot.retryAfterMs / 1000)} 秒后可再试。`)
|
|
498
615
|
}
|
|
499
616
|
|
|
500
|
-
const text = composeBusText({ workspace, fromRole: callerRole, toRole: targetRole, kind, ref, message })
|
|
617
|
+
const text = composeBusText({ workspace, fromRole: callerRole, toRole: targetRole, kind, ref, message, seq })
|
|
501
618
|
const payload = createUserMessage([{ type: 'text', text }], {
|
|
502
619
|
kind: 'handoff-bus',
|
|
503
620
|
workspace,
|
|
@@ -526,11 +643,20 @@ export function apply(ctx, config) {
|
|
|
526
643
|
targetSessionId: target.sessionId,
|
|
527
644
|
kind,
|
|
528
645
|
...(ref ? { ref } : {}),
|
|
646
|
+
...(kind === '指令' ? { seq } : {}),
|
|
529
647
|
messageId: payload.id,
|
|
530
648
|
woken: wantWake,
|
|
531
649
|
chars: text.length,
|
|
532
650
|
})
|
|
533
651
|
|
|
652
|
+
if (kind === '指令') {
|
|
653
|
+
bus.state.seq.set(pairKey, seq)
|
|
654
|
+
bus.state.lastInstruction.set(pairKey, { seq, at: sentAt, ...(ref ? { ref } : {}) })
|
|
655
|
+
} else if (kind === '回报') {
|
|
656
|
+
// This report answers the reversed pair: worker-a→main acks main→worker-a.
|
|
657
|
+
bus.state.lastReport.set(`${targetRole}->${callerRole}`, sentAt)
|
|
658
|
+
}
|
|
659
|
+
|
|
534
660
|
return {
|
|
535
661
|
ok: true,
|
|
536
662
|
workspace,
|
|
@@ -539,6 +665,14 @@ export function apply(ctx, config) {
|
|
|
539
665
|
messageId: payload.id,
|
|
540
666
|
sentAt,
|
|
541
667
|
woken: wantWake,
|
|
668
|
+
...(kind === '指令' ? { seq } : {}),
|
|
669
|
+
...(blocked
|
|
670
|
+
? {
|
|
671
|
+
warning:
|
|
672
|
+
`已强制下发:${targetRole} 上一条指令${blocked.ref ? `(${blocked.ref})` : ''}尚未回报,` +
|
|
673
|
+
'它很可能先执行旧版本,再看到这一条。',
|
|
674
|
+
}
|
|
675
|
+
: {}),
|
|
542
676
|
}
|
|
543
677
|
},
|
|
544
678
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-handoff-bus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Cross-session message bus for DeepSeek Harness GUI conversations: deliver instructions and reports directly between the main conversation and sub-conversations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|