dsh-handoff-bus 0.3.0 → 0.4.2

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,171 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 — 2026-10-19
4
+
5
+ ### Fixed — tool results and the retire decision read the same number
6
+
7
+ `contextReport` (behind `bus_register` / `bus_whoami` / `bus_list` / `bus_send`)
8
+ counted characters and added the 40,000-token constant, while retirement and
9
+ drain were decided from the host's real projection. A fresh conversation
10
+ therefore read `7.6%` (40000 / 524288) while the plugin could already be treating
11
+ it as 20%, 78% or worse. Showing a second, disagreeing number is worse than
12
+ showing none: the human plans around the figure on screen.
13
+
14
+ `contextReport` is now async and goes through `measureSession` — the same probe,
15
+ same 60-second cache, same `min(declared window, contextWindowTokens)` divisor —
16
+ and derives `level` from `contextTier`, so the reported tier is the tier that
17
+ actually retires the session. The character estimate survives only when the host
18
+ offers nothing to measure, and then the result carries `estimated: true`.
19
+
20
+ `context.tokens` is only meaningful with a projection; when only the compaction
21
+ count can be read, `tokens`/`pct` are reported as 0 with `estimated: true`, and
22
+ the `bus_send` overflow warning uses a phrase that refuses to print "0%".
23
+
24
+ ## 0.4.1 — 2026-10-19
25
+
26
+ Four defects found by running 0.4.0 for real. Three of them had nothing to do
27
+ with the queue logic and everything to do with what the text told the model.
28
+
29
+ ### Fixed — an instruction no longer tells the target to leave
30
+
31
+ `composeBusText` appended 「任务做完就 bus_unregister,不用等人提醒」 to every
32
+ instruction. Models followed it literally: a conversation that reported at
33
+ 12:45:13 was retired at 12:45:14, twice in a row, one second later. The line went
34
+ empty, so the next instruction needed a brand-new conversation and a cold
35
+ context — the exact cost the bus exists to avoid.
36
+
37
+ The rule now says the opposite: **report, then stand by**. Retire only when the
38
+ context is full or the user says the work is over. `bus_unregister`'s own
39
+ description was rewritten for the same reason.
40
+
41
+ ### Fixed — `handoff/` is created, and existing state is migrated
42
+
43
+ Nothing ever created the directory. Reports landed in the workspace root as
44
+ `.txt`, and `stateDirFor` quietly fell back to `~/.dsh/handoff-bus/<slug>/`
45
+ where nobody would look for them. Worse, the first person to create the folder
46
+ by hand would have silently orphaned every binding.
47
+
48
+ Registration now creates it, and when a central state directory already exists
49
+ it is copied in before the bus is resolved, so counters and bindings survive.
50
+ Every prompt that names a file now names `handoff/<role>.md`.
51
+
52
+ ### Fixed — `role: "a"` no longer turns a line into a conversation
53
+
54
+ `bus_register` accepted a bare line name as `role`, and its parameter text
55
+ actively suggested it. Binding the line itself makes routing short-circuit to
56
+ that session, so the numbered substitutes never receive anything.
57
+
58
+ A bare line name passed as `role` is now coerced to `parent` and reported as
59
+ `coercedFrom`, so the wrong parameter cannot produce the wrong topology.
60
+
61
+ ### Added — 「注册 a」 is defined, once, for a fresh conversation
62
+
63
+ An unregistered conversation is told the three-level shape and the three steps:
64
+ `bus_register(parent="a")` → read `handoff/a/` → report. The number is allocated
65
+ by the plugin, so nobody has to remember which one is next.
66
+
67
+ It also states where the newcomer stands: `a.6` behind an idle `a.3` is a
68
+ **substitute**, and tells it to stand by. Stated only — the queue order is
69
+ untouched.
70
+
71
+ The hint is deliberately independent of the start-up briefing, which stays silent
72
+ until the folder has files — precisely the state a fresh workspace is in.
73
+
74
+ ## 0.4.0 — 2026-10-19
75
+
76
+ 0.3.0 gave every conversation a budget and a briefing. 0.4.0 makes the bus
77
+ **hold the queue itself**: numbering, routing, back-pressure and retirement all
78
+ move from "the model remembers to do it" to "the bus refuses otherwise".
79
+
80
+ ### Changed — routing is a queue, not a popularity contest
81
+
82
+ `bus_send(target="rgb")` used to pick the most recently active conversation,
83
+ which meant a pre-registered `rgb.2` and `rgb.3` (activity 0) could never be
84
+ chosen. It now picks the **lowest number** among candidates that are not retired
85
+ and hold no unfinished instruction. Pre-registered conversations become what they
86
+ always were meant to be: a bench, not a race.
87
+
88
+ ### Changed — child numbers are monotonic and never reused
89
+
90
+ `rgb.1` archived means the next one is `rgb.4`, never `rgb.1` again. The
91
+ high-water mark is persisted in `bindings.counters` because the `retired` archive
92
+ is pruned after `retiredRetentionDays` (7) — a counter that lived there would
93
+ recycle numbers after a week, silently moving an old instruction's meaning onto a
94
+ different conversation. v1/v2 files derive the counter from their existing roles.
95
+
96
+ ### Changed — back-pressure has no time escape
97
+
98
+ `pendingInstructionTtlSec` (600s) freed the line automatically, so a long task
99
+ was never protected. Removed: a line owns **one** instruction until it reports.
100
+ `force` is gone from `bus_send` too — it was the same escape under another name.
101
+ A dead conversation releases the line by leaving the role table, not by waiting.
102
+
103
+ ### Added — `line_busy` and `no_available_session`
104
+
105
+ Two distinct refusals instead of one vague one: `line_busy` means the line has
106
+ live conversations but all are working; `no_available_session` means everyone is
107
+ retired, or there is nobody at all.
108
+
109
+ ### Added — `preempt` (the only way to take a busy line)
110
+
111
+ `preempt: true` on a 指令 delivers via `inject` into the running turn (no new
112
+ turn), forces the body to open with "stop the current task, save the half-done
113
+ work, then switch", and **cancels** the instruction it displaced. A late report
114
+ for the cancelled ref is recorded but never counts as an acknowledgement — a
115
+ stale task finishing must not free the line for the task that replaced it.
116
+
117
+ ### Added — retirement on measured context
118
+
119
+ Compaction count and water level are read from the host for **any** session
120
+ (`sessionQuery.observeSession` for `compaction/end`, the projection cache for
121
+ `contextWindow` and the live sums) instead of being estimated from the current
122
+ message list.
123
+
124
+ | | advise | stop |
125
+ |---|---|---|
126
+ | compactions | ≥ 1 | ≥ 2 |
127
+ | water level | > 50% | > 70% |
128
+
129
+ **Compaction count wins outright.** Once a conversation has been compacted, the
130
+ percentage it reports afterwards is meaningless — compaction is what resets it.
131
+ So ≥ 2 compactions stops a conversation whatever the level says, and 1 compaction
132
+ only advises even at 78%.
133
+
134
+ Retirement only marks: `retired: true`, `retiredReason: "context_full"`, and the
135
+ level/compaction snapshot at that moment. The session, its context and its place
136
+ in the tree all stay. **A conversation is only retired when it is idle** — on its
137
+ own report, or when routing finds it free — so a task is never cut in half.
138
+
139
+ The bus does not tell the user "rgb.1 should be archived": it drops that sentence
140
+ into `rgb.1`'s own history (`inject`, no wake) and lets the child say it to its own
141
+ window, with the number to ask for next.
142
+
143
+ ### Added — the main conversation drains instead of stopping
144
+
145
+ A main conversation at **≥ 2 compactions or > 85%** (wider than a child, because
146
+ stopping it stops the whole workspace) enters *drain*: it stops issuing
147
+ instructions and keeps receiving reports. Nothing interrupts the lines — they
148
+ finish at their own pace. A sweep (900s, backing off to 3600s after 3 quiet
149
+ rounds) watches for every line to go idle; only then is the user told to archive
150
+ and open a new main. No hard stop: a draining conversation being compacted costs
151
+ nothing, because all it needs to remember is "wait".
152
+
153
+ New error codes `draining` and `self_retired`. A new conversation calling
154
+ `bus_register(role="main")` takes the binding over and resets the state; child
155
+ bindings and numbers are untouched.
156
+
157
+ ### Added — departure reminder on every instruction
158
+
159
+ Every 指令 ends with "任务做完就 bus_unregister,不用等人提醒" — one line, so the
160
+ next shift does not depend on the user saying "收工".
161
+
162
+ ### Config
163
+
164
+ Removed: `blockSecondInstruction`, `pendingInstructionTtlSec`.
165
+ Added: `childWarnRatio`, `childStopRatio`, `mainWarnRatio`, `mainDrainRatio`,
166
+ `compactionWarn`, `compactionStop`, `contextProbeSec`, `drainSweepSec`,
167
+ `drainSweepIdleRounds`, `drainSweepBackoffSec`.
168
+
3
169
  ## 0.3.0 — 2026-10-19
4
170
 
5
171
  The previous release made short conversations possible; this one makes them
package/README.md CHANGED
@@ -35,9 +35,10 @@ you ──open the main conversation──▶ main (bus_register role="main")
35
35
  │ 4. bus_send(target="main", kind="回报", ref="R-…")
36
36
  ▼
37
37
  main (woken once per few reports)
38
- │ 5. this task is done: unregister rgb.1, open rgb.2 for the next one
38
+ │ 5. rgb.1 stays and waits; only when its context is full
39
+ │ (or the user is done) does it unregister, and rgb.2 takes over
39
40
  ▼
40
- rgb.2 (bus_register parent="rgb" replaces="rgb.1")
41
+ rgb.2 (bus_register parent="rgb")
41
42
  ```
42
43
 
43
44
  Keep the bus message short: summary + file pointers. The authoritative content stays in your files.
@@ -48,7 +49,7 @@ Keep the bus message short: summary + file pointers. The authoritative content s
48
49
  - Tested on DSH `0.2.0-rc.x` (desktop, Windows). Host services such as `sessionController.resolveAgent`, `agent.followup/inject`, `sessions.flush`, `session.header.cwd`, the `agent/created` event and the `agent/pre-step` hook are internal APIs, not a public contract; a future DSH release may require a patch. If the hook throws, the plugin degrades to "no advisory" and the turn proceeds untouched.
49
50
  - No npm dependencies. The plugin imports only Node builtins.
50
51
  - Tool result messages and relayed message bodies are in Chinese; error codes are stable English identifiers. Role names are lowercase ASCII with dots (`main`, `rgb`, `rgb.1`).
51
- - **No context-length reading**: the plugin does not touch the host's projection API, so `bus_list` reports bus activity (message count, idle minutes), not token water level. Use dsh-context's `/context` for the real number.
52
+ - **Context water level comes from the host**: `sessionQuery.observeSession` counts compactions from the event log, `sessionProjectionCache.cachedSnapshot` reads the `contextTimeline` bucket sums and window. Both are internal services; when they are unavailable the plugin falls back to a character estimate and flags it `estimated: true`. `bus_list.contextPct` only appears for conversations that have been measured.
52
53
  - **No session creation or closing**: both are GUI-only actions.
53
54
 
54
55
  ## Install
@@ -109,22 +110,44 @@ Role names are lowercase letters, digits, dots, underscores and hyphens, at most
109
110
  bus_register(parent="rgb") → role: "rgb.1"
110
111
  bus_register(parent="rgb", note="calibration") → role: "rgb.2"
111
112
  bus_register(role="main") → the main line
112
- bus_register(role="rgb") → one conversation owns the line itself (optional)
113
+ bus_register(role="rgb") → same as parent="rgb": you get rgb.N (see below)
113
114
  ```
114
115
 
115
116
  All you say in each new sub-conversation:
116
117
 
117
- > Take work on the rgb line: call `bus_register(parent="rgb")` first, then read `handoff/rgb/STATE.md`.
118
+ > 注册 rgb
119
+
120
+ The plugin sends that conversation a fixed three-step note on its first request
121
+ (`bus_register(parent="rgb")` → read `handoff/rgb/` → report), so the model never
122
+ needs to know which number is next, nor guess between `role` and `parent`.
123
+
124
+ **A line name never becomes a conversation.** `role="rgb"` used to bind the line
125
+ itself, after which routing short-circuited to it and the numbered conversations
126
+ never received anything. A bare line name passed as `role` is now coerced to
127
+ `parent`, and the result says so via `coercedFrom`.
128
+
129
+ Registration also tells you where you stand: if a smaller-numbered conversation
130
+ is already on the line, you are a **substitute** and should stand by. That is
131
+ stated, not enforced — the queue order is untouched, the lowest number always
132
+ wins.
118
133
 
119
134
  ### How a target is resolved
120
135
 
121
136
  1. `target="rgb.2"` → that exact conversation, no guessing.
122
- 2. `target="rgb"` → inside the `rgb` subtree, sorted by most recent traffic, each candidate is opened in turn and **the first one that opens wins** (at most `routeMaxAttempts`, default 3).
123
- 3. None opens → `no_live_session`, with the hint to open a fresh conversation and `bus_register(parent="rgb")`.
137
+ 2. `target="rgb"` → the line **queue**: candidates are the conversations that are
138
+ not retired and hold no unfinished instruction, sorted by number, and the
139
+ **lowest number wins**. Each is opened in turn until one resolves (at most
140
+ `routeMaxAttempts`, default 3).
141
+ 3. Nobody usable → `line_busy` (there are conversations, all working) or
142
+ `no_available_session` (all retired, or the line is empty).
124
143
 
125
144
  The result carries `routedFrom` / `routedTo`, so nothing is a black box. `bus_list.routeTo` tells you where it would land before you send.
126
145
 
127
- > **Keep one live conversation per line.** Most-recently-active is not "best suited": if `rgb.2` is busy with task A and you send B to `rgb`, it lands on `rgb.2` behind A. Unregister the old conversation when its task is done, then open a new one — then `target="rgb"` is never ambiguous.
146
+ > **A line owns one instruction at a time.** A bench is not a parallel pool: while
147
+ > `rgb.1` owes a 回报, `rgb.2` does not sneak in — the send is refused, and naming
148
+ > `rgb.2` explicitly does not help either. There is no `force`; the only override
149
+ > is `preempt: true`, which injects "stop and save, then switch" into the running
150
+ > turn and cancels the displaced instruction.
128
151
 
129
152
  ### Handing a line over
130
153
 
@@ -136,13 +159,43 @@ The old conversation moves to `retired` (kept for `retiredRetentionDays`, defaul
136
159
 
137
160
  ### When to archive a temporary conversation
138
161
 
139
- All three at once:
162
+ The bus now decides this from **measured** context (compaction count first, water
163
+ level second) and marks the conversation `retired` when it is idle:
164
+
165
+ | | advise | stop taking work |
166
+ |---|---|---|
167
+ | compactions | ≥ 1 | ≥ 2 |
168
+ | water level | > 50% | > 70% |
169
+
170
+ A retired conversation keeps its context and its place in the tree; routing simply
171
+ skips it. The bus does not lecture the user from the outside — it drops one line
172
+ into the retired conversation's own history, and that conversation tells its own
173
+ window: *"archive me, the next one will be `rgb.4`"*.
140
174
 
141
- 1. `bus_list` shows `awaiting: false` (no unanswered instruction);
142
- 2. the current task is reported and has no follow-up;
143
- 3. `suggestHandoff: true` (default: 40 messages exchanged, or 30 minutes idle).
175
+ `suggestHandoff` still exists as a softer hint (40 messages, or 30 minutes idle)
176
+ for conversations that are merely stale rather than full. `bus_list` also exposes
177
+ `compactions` and `contextPct` once a conversation has been measured.
144
178
 
145
- `suggestHandoff` is a **heuristic, not a context gauge**. `msgCount` only counts bus messages; the context a conversation accumulates by reading files and running commands is invisible to the bus — use dsh-context's `/context` for the real number.
179
+ ### When the main conversation has to go
180
+
181
+ The main conversation fills up too, but its line is wider: **≥ 2 compactions or
182
+ > 85%**. Stopping it stops the whole workspace, so the cost is not the same as
183
+ swapping a temporary conversation.
184
+
185
+ It is never cut short. It **drains**:
186
+
187
+ - it stops issuing instructions (`draining` is returned) and keeps receiving reports;
188
+ - no line is interrupted — they finish at their own pace;
189
+ - a background sweep checks every 900s whether every line is idle, backing off to
190
+ 1 hour after 3 quiet rounds, because a long task does not deserve to be poked;
191
+ - the moment everything is idle, it says: write the ledger, archive me, open a new
192
+ main and say "take main".
193
+
194
+ A new main calling `bus_register(role="main")` takes the name over; child bindings
195
+ and numbers are untouched, and their reports land on the new main automatically.
196
+
197
+ Being compacted while draining is harmless — at that point it only has to remember
198
+ one word: wait.
146
199
 
147
200
  Rule of thumb: one tool call adds roughly 2–3k tokens, so 150–200 tool calls lands around 400k. **A line is ready to hand off after 8–10 turns**, before it starts forgetting things.
148
201
 
@@ -186,9 +239,12 @@ Every delivery is a separate turn in the target conversation. If `main` sends th
186
239
  are executed in order — the first one runs against a task file that is already at
187
240
  version three. Three consequences:
188
241
 
189
- - **A second `指令` is refused** while the previous one has no `回报`
190
- (`instruction_pending`). Update the task file, wait for the report, then dispatch
191
- again; `force: true` overrides when you really mean to queue.
242
+ - **A second `指令` is refused** while the line still owes a `回报` (`line_busy`).
243
+ Update the task file, wait for the report, then dispatch again. There is no
244
+ `force` and no timeout: a long task keeps the line. To take the line anyway,
245
+ `preempt: true` — it injects into the running turn, orders "stop and save the
246
+ half-done work", and cancels what it displaced. A late report for the cancelled
247
+ `ref` is recorded but never frees the line.
192
248
  - **Each `指令` carries its own sequence** ("本线第 3 条指令") and the footer tells the
193
249
  receiver to compare it with `bus_list.latest`. A `seq` below the latest means the
194
250
  message was superseded: do the newest one, skip the duplicate work.
@@ -230,13 +286,21 @@ files):
230
286
  1. 全文读:_HANDOFF/README.md、_HANDOFF/01-铁律.md
231
287
  2. 只读结构与末尾:_HANDOFF/41-回报-B线.md —— 先 grep -n '^## ' 看标题,再 tail -n 80 看最新回执;
232
288
  3. 要细节再按标题定位:sed -n 'A,Bp' 文件;不要 cat 整篇,也不要把大段日志贴进对话。
233
- 收工规矩:把状态写回 _HANDOFF/(含未回报的指令 ref 与产物路径),否则 bus_unregister 会被拒。
289
+ 状态规矩:写回 _HANDOFF/&lt;你的角色&gt;.md(.md,不是 .txt;含未回报的指令 ref 与产物路径)。不收工不要注销,做完回报后待命即可。
234
290
  ```
235
291
 
236
292
  The estimate is derived from the prompt text (ASCII ≈ 3.8 chars/token, CJK ≈ 1.6)
237
293
  plus `contextOverheadTokens` for the system prompt and tool definitions. It is an
238
294
  estimate within roughly 15%, not the host's exact number.
239
295
 
296
+ **The estimate is only the fallback.** For a registered conversation, the `context`
297
+ in a tool result is the same measurement the retirement decision uses (the host's
298
+ projection buckets plus the compaction count); only when the host cannot be probed
299
+ does it fall back to the character estimate, flagged `estimated: true`. Two
300
+ different numbers would be worse than one imprecise number: `bus_list` once read
301
+ 7.6% (the 40,000 constant over an empty message list) while the plugin was already
302
+ treating the same session as 78%.
303
+
240
304
  `contextWindowTokens` defaults to 524288, not the declared 1048576: in practice
241
305
  `context overflow` starts around 430k, so 40% of the declared window would fire
242
306
  after it is already too late.
@@ -260,7 +324,7 @@ handover gate); nobody has to watch it.
260
324
 
261
325
  Ways to spend less, best first:
262
326
 
263
- 1. **Shorten conversation life** (biggest win): one task per conversation, `bus_unregister` when done, open a fresh one for the next task. Going from 400k to 20k makes the same work an order of magnitude cheaper.
327
+ 1. **Never run one line to the end** (biggest win): hand it off when it reaches the retirement line, before it starts forgetting and re-reading the same file. **But leaving right after a task is pure waste** — report, then stand by; the next instruction reuses the same warm context. Leaving early means the next instruction needs a brand-new conversation with a cold history.
264
328
  2. **Stop pasting logs into the conversation**: write them to a file, grep the hits, paste only those lines.
265
329
  3. **Make `done` specific** — it removes the "one more thing" follow-up that costs a whole context reload.
266
330
  4. **Reserve `urgent` for stuck/failed/decision** — everything else rides the digest.
@@ -323,21 +387,24 @@ Add urgent: true when stuck, failed, or you need a decision.
323
387
  - State location:
324
388
  - workspace has `handoff/` → `<workspace>/handoff/.bus/`
325
389
  - otherwise → `<DSH_HOME>/handoff-bus/<sanitized-workspace>/`
390
+ - The first `bus_register` creates `handoff/`, migrating an existing central
391
+ state into it first, so the move never orphans bindings or counters.
326
392
  - Cross-workspace addressing is not implemented. Open an issue if you need it.
327
393
 
328
394
  ## Tools
329
395
 
330
396
  | Tool | Purpose |
331
397
  |---|---|
332
- | `bus_register(role?, parent?, replaces?, note?)` | Register this conversation. `parent` alone allocates the next free child; `replaces` takes a line over from an older conversation |
333
- | `bus_unregister(role?, force?)` | Leave the table when the task is done, so the line routes to a fresh conversation. Refused while an instruction is unanswered |
398
+ | `bus_register(role?, parent?, replaces?, note?)` | Register this conversation. `parent` alone allocates the next free child; a bare line name in `role` is coerced to `parent` (`coercedFrom`). Result carries `queue` standing |
399
+ | `bus_unregister(role?, force?)` | Leave the table **only when archiving (context full) or the user says the work is over** — not after each task. Refused while an instruction is unanswered |
334
400
  | `bus_whoami()` | Current role + workspace |
335
401
  | `bus_list()` | Role tree (`parent` / `depth` / `children` / `virtual`) with signals (`awaiting` / `msgCount` / `idleMin` / `routeTo` / `suggestHandoff`) plus `latest` |
336
- | `bus_send(target, message, kind?, done?, ref?, urgent?, wake?, force?)` | Deliver to a role or a line; a line name routes automatically and the result carries `routedFrom` / `routedTo` |
402
+ | `bus_send(target, message, kind?, done?, ref?, urgent?, wake?, preempt?)` | Deliver to a role or a line; a line name queues to the lowest-numbered usable conversation and the result carries `routedFrom` / `routedTo` |
337
403
 
338
404
  `kind`: `指令` (instruction), `回报` (report), `通知` (notice), `请求` (request). These four values are literal tokens — keep them in Chinese; do not translate them.
339
405
 
340
- Every result except `bus_unregister` carries `context` (estimated `pct` / `tokens` / `level`);
406
+ `bus_whoami` / `bus_list` / `bus_send` carry `context` (measured `pct` / `tokens` / `level` /
407
+ `compactions`; `estimated: true` when the host could not be probed);
341
408
  `bus_register` / `bus_whoami` / `bus_list` also carry `handoff` (directory, entry files, minutes
342
409
  since the last write); `bus_send` adds `targetContext` and warns when the target is already past
343
410
  the watermark — archive it instead of piling on more work.
@@ -361,8 +428,6 @@ Example `config.json`:
361
428
  "maxMessageChars": 4000,
362
429
  "maxSendsPerHour": 30,
363
430
  "maxPairSendsPer10Min": 8,
364
- "blockSecondInstruction": true,
365
- "pendingInstructionTtlSec": 600,
366
431
  "requireDone": true,
367
432
  "routeMaxAttempts": 3,
368
433
  "retiredRetentionDays": 7,
@@ -381,6 +446,16 @@ Example `config.json`:
381
446
  "contextWarnRatio": 0.4,
382
447
  "contextStopRatio": 0.7,
383
448
  "contextOverheadTokens": 40000,
449
+ "childWarnRatio": 0.5,
450
+ "childStopRatio": 0.7,
451
+ "mainWarnRatio": 0.5,
452
+ "mainDrainRatio": 0.85,
453
+ "compactionWarn": 1,
454
+ "compactionStop": 2,
455
+ "contextProbeSec": 60,
456
+ "drainSweepSec": 900,
457
+ "drainSweepIdleRounds": 3,
458
+ "drainSweepBackoffSec": 3600,
384
459
  "requireHandoverOnLeave": true,
385
460
  "handoverWindowMin": 30
386
461
  }
@@ -396,8 +471,6 @@ Example `config.json`:
396
471
  | `maxMessageChars` | 4000 | Per-message limit |
397
472
  | `maxSendsPerHour` | 30 | Per-conversation hourly limit |
398
473
  | `maxPairSendsPer10Min` | 8 | Per role-pair limit, to stop ping-pong |
399
- | `blockSecondInstruction` | `true` | Refuse a `指令` while the previous one to the same target has no `回报` yet |
400
- | `pendingInstructionTtlSec` | 600 | After this long without a `回报`, the pending instruction stops blocking |
401
474
  | `requireDone` | `true` | `kind="指令"` must carry `done` |
402
475
  | `routeMaxAttempts` | 3 | Candidates probed when `target` is a line name |
403
476
  | `retiredRetentionDays` | 7 | How long retired roles are remembered |
@@ -414,8 +487,15 @@ Example `config.json`:
414
487
  | `contextBudget` | `true` | Context watermark notices |
415
488
  | `contextWindowTokens` | 524288 | What counts as 100% (not the declared 1048576) |
416
489
  | `contextWarnRatio` | 0.4 | Warn: finish the current task, write the ledger, switch conversations |
417
- | `contextStopRatio` | 0.7 | Demand an immediate wrap-up |
490
+ | `contextStopRatio` | 0.7 | Generic watermark for conversations that are not registered on the bus |
418
491
  | `contextOverheadTokens` | 40000 | Fixed cost of the system prompt and tool definitions |
492
+ | `childWarnRatio` / `childStopRatio` | 0.5 / 0.7 | Water level at which a line conversation is advised / retired |
493
+ | `mainWarnRatio` / `mainDrainRatio` | 0.5 / 0.85 | Water level at which the main conversation is advised / starts draining |
494
+ | `compactionWarn` / `compactionStop` | 1 / 2 | Compaction count that advises / retires (outranks the level) |
495
+ | `contextProbeSec` | 60 | How long a measured context is reused |
496
+ | `drainSweepSec` | 900 | Idle check interval while the main conversation drains |
497
+ | `drainSweepIdleRounds` | 3 | Quiet rounds before backing off |
498
+ | `drainSweepBackoffSec` | 3600 | Backed-off interval |
419
499
  | `requireHandoverOnLeave` | `true` | Refuse `bus_unregister` when the coordination directory is stale |
420
500
  | `handoverWindowMin` | 30 | Window for the check above, in minutes |
421
501
 
@@ -430,12 +510,15 @@ Example `config.json`:
430
510
  | `invalid_role` / `invalid_target` / `invalid_message` / `invalid_kind` | Invalid argument |
431
511
  | `caller_not_registered` | This conversation has no role yet |
432
512
  | `target_not_registered` | The target role or line does not exist in this workspace |
433
- | `no_live_session` | The line has no live conversation left; open one and register it |
513
+ | `line_busy` | A conversation on this line still owes a `回报`; wait, or `preempt: true` |
514
+ | `no_available_session` | Every conversation on the line is retired (context full), or the line is empty |
515
+ | `draining` | This main conversation is winding down: reports only, no new instructions |
516
+ | `self_retired` | This conversation is retired (context full): write the ledger, ask the user to archive it |
434
517
  | `role_not_found` / `role_virtual` / `not_owner` | `bus_unregister`: no such role / it is a virtual line node / it belongs to another conversation |
435
518
  | `self_target` | The target is this conversation |
436
519
  | `target_unresolvable` | The target session cannot be opened (archived/deleted, or owned by subagent routing) |
437
520
  | `rate_limited` | Rate limit hit |
438
- | `instruction_pending` | A `指令` to this target is still unanswered; wait for the `回报`, or pass `force: true` |
521
+ | `instruction_pending` | `bus_unregister` only: an instruction to this role is still unanswered |
439
522
  | `instruction_incomplete` | `kind="指令"` is missing `done` |
440
523
  | `handover_missing` | The coordination directory was not written before leaving; write the state, or pass `force: true` |
441
524
  | `flush_failed` | Queued but persistence was not acknowledged |