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 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` | 工具调用被取消 |
@@ -3,5 +3,7 @@
3
3
  "stateDirName": ".bus",
4
4
  "maxMessageChars": 4000,
5
5
  "maxSendsPerHour": 30,
6
- "maxPairSendsPer10Min": 8
6
+ "maxPairSendsPer10Min": 8,
7
+ "blockSecondInstruction": true,
8
+ "pendingInstructionTtlSec": 600
7
9
  }
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: path.join(dir, 'deliveries.jsonl'),
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(`执行完后用 bus_send 回报「${fromRole}」(kind=回报,ref 带指令编号);不要自动回执,也不要转发给其他线。`)
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
- `无法打开目标会话(可能已归档/删除):${resolved?.error?.message ?? String(resolved?.error ?? 'unknown')}`,
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.0",
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",