dsh-handoff-bus 0.1.0 → 0.3.0
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 +156 -0
- package/README.md +283 -37
- package/README.zh.md +265 -27
- package/config.example.json +23 -1
- package/index.js +1200 -86
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,161 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
The previous release made short conversations possible; this one makes them
|
|
6
|
+
**automatic**. Three things that a person had to remember are now enforced by the
|
|
7
|
+
plugin, and all three attack the same number: how many tokens a conversation
|
|
8
|
+
carries into every single request.
|
|
9
|
+
|
|
10
|
+
### Added — start-up briefing
|
|
11
|
+
|
|
12
|
+
Before a conversation's first request, the plugin appends one short message that
|
|
13
|
+
tells it which handoff files to read and how:
|
|
14
|
+
|
|
15
|
+
- `briefingFiles` (default `README.md`, `PROTOCOL.md`) are read in full;
|
|
16
|
+
- `briefingScan` globs are read as **structure + tail** — `grep -n '^## '` for the
|
|
17
|
+
headings, then `tail -n briefingTailLines` for the newest entries;
|
|
18
|
+
- everything else is located by heading and read with `sed -n 'A,Bp'`.
|
|
19
|
+
|
|
20
|
+
Reading a 95 KB ledger in full costs ~30k tokens on the very first request; this
|
|
21
|
+
costs ~200 and is announced once per conversation (`briefing: false` disables it).
|
|
22
|
+
Only fires when the workspace actually has a handoff directory.
|
|
23
|
+
|
|
24
|
+
### Added — context budget
|
|
25
|
+
|
|
26
|
+
Each conversation's prompt is measured before it is sent (ASCII ≈ 3.8 chars per
|
|
27
|
+
token, CJK ≈ 1.6, plus `contextOverheadTokens` for the system prompt and tool
|
|
28
|
+
definitions). At `contextWarnRatio` (default 0.4) the conversation is told:
|
|
29
|
+
|
|
30
|
+
- finish the current task, start nothing new;
|
|
31
|
+
- write the state — including unanswered instruction refs and artefact paths —
|
|
32
|
+
into the handoff directory;
|
|
33
|
+
- tell the user the context is too full and a new conversation should take over,
|
|
34
|
+
then stop.
|
|
35
|
+
|
|
36
|
+
At `contextStopRatio` (default 0.7) the wording becomes an instruction to stop
|
|
37
|
+
after the current step. Both are ordinary user messages appended to the same
|
|
38
|
+
request: **no extra turn, no extra model call.** Each level is announced once and
|
|
39
|
+
re-armed if a compaction brings the estimate back down.
|
|
40
|
+
|
|
41
|
+
`contextWindowTokens` defaults to 524288 rather than the declared 1048576,
|
|
42
|
+
because real overflow happens well before the declared window. Set it to your
|
|
43
|
+
provider's real window if you know it.
|
|
44
|
+
|
|
45
|
+
### Added — handover before leaving
|
|
46
|
+
|
|
47
|
+
`bus_unregister` now refuses to unbind a conversation when the handoff directory
|
|
48
|
+
has not been written for `handoverWindowMin` (default 30) minutes, returning
|
|
49
|
+
`handover_missing`. Leaving is the moment a conversation's context dies; the files
|
|
50
|
+
are all the next shift inherits. `force: true` overrides, and the result reports
|
|
51
|
+
how fresh the handoff is.
|
|
52
|
+
|
|
53
|
+
### Added — budget visible in tool results
|
|
54
|
+
|
|
55
|
+
`bus_register` / `bus_whoami` / `bus_list` / `bus_send` carry a `context` block
|
|
56
|
+
(`pct`, `tokens`, `level`), and `bus_send` also reports `targetContext`. Sending an
|
|
57
|
+
instruction to a conversation that is already at 60% now returns a warning telling
|
|
58
|
+
you to archive it instead of piling more work onto a stale context.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- "Unanswered instruction" was decided by comparing ISO timestamps with `>=`.
|
|
63
|
+
Several deliveries inside one turn share a millisecond, so a report could be
|
|
64
|
+
treated as acknowledging an instruction that came **after** it, silently
|
|
65
|
+
clearing the pending state (and with it the back-pressure and the takeover
|
|
66
|
+
warning). The delivery log now carries a monotonic `order`, replayed from the
|
|
67
|
+
file, so restarts keep the correct answer. Existing `deliveries.jsonl` files
|
|
68
|
+
replay correctly without migration.
|
|
69
|
+
|
|
70
|
+
## 0.2.0 — 2026-10-19
|
|
71
|
+
|
|
72
|
+
Shorter conversations are the only lever that actually lowers cost: a conversation
|
|
73
|
+
sitting at 400k tokens pays that much again on every request, and one at 20k pays
|
|
74
|
+
one twentieth. This release makes "one temporary conversation per task" cheap to
|
|
75
|
+
run and hard to forget.
|
|
76
|
+
|
|
77
|
+
### Added — three-level roles
|
|
78
|
+
|
|
79
|
+
- `bus_register(parent="rgb")` allocates the next free child (`rgb.1`, `rgb.2`, …).
|
|
80
|
+
Nobody has to remember or pre-plan numbers.
|
|
81
|
+
- Registering a dotted role materialises its parent as a **virtual node** (a name
|
|
82
|
+
with no session attached), so lines never need a separate registration step.
|
|
83
|
+
- `bus_send(target="rgb")` routes to the most recently active live conversation
|
|
84
|
+
under that line and reports `routedFrom` / `routedTo`. Naming `rgb.2` explicitly
|
|
85
|
+
still wins. At most `routeMaxAttempts` (3) candidates are probed.
|
|
86
|
+
- `bus_register(replaces="rgb.2")` hands a line over: the old conversation is
|
|
87
|
+
retired, and the result warns when it still had an unanswered instruction (read
|
|
88
|
+
the ledger, do not redo the work from scratch).
|
|
89
|
+
- New tool `bus_unregister` lets a finished conversation leave the table, so the
|
|
90
|
+
line routes to a fresh one. Refused while an instruction is still awaiting a
|
|
91
|
+
report; `force: true` overrides.
|
|
92
|
+
- `bus_list` now returns the tree with `parent` / `depth` / `children` / `virtual`
|
|
93
|
+
/ `awaiting` / `msgCount` / `idleMin` / `routeTo` / `suggestHandoff`.
|
|
94
|
+
- `bindings.json` upgrades from v1 to v2 on read; existing files keep working.
|
|
95
|
+
|
|
96
|
+
### Added — `done` is required on every 指令
|
|
97
|
+
|
|
98
|
+
`bus_send(kind="指令")` without `done` returns `instruction_incomplete`. An
|
|
99
|
+
instruction that does not say what "finished" means invites a second instruction,
|
|
100
|
+
and a second instruction is a whole extra turn of the target's context — the most
|
|
101
|
+
expensive way to say six words. `done` is echoed in the delivered message, so the
|
|
102
|
+
report can be checked against it. Disable with `requireDone: false`.
|
|
103
|
+
|
|
104
|
+
### Added — report digest and `urgent`
|
|
105
|
+
|
|
106
|
+
A plain `回报` is now injected into the target's history **without waking it**, and
|
|
107
|
+
the target is woken once when `digestMinCount` reports (default 3) or
|
|
108
|
+
`digestMaxWaitSec` (default 900s) have accumulated. The digest message lists the
|
|
109
|
+
reports and says the full text is already above it.
|
|
110
|
+
|
|
111
|
+
- `urgent: true` wakes immediately (stuck / failed / decision needed).
|
|
112
|
+
- `kind="请求"` is urgent by default.
|
|
113
|
+
- An urgent message folds the queued backlog into the same wake-up instead of
|
|
114
|
+
waking twice.
|
|
115
|
+
- `wake: true` / `wake: false` still override everything explicitly.
|
|
116
|
+
- The only timer in the plugin exists while reports are queued, and is `unref`'d.
|
|
117
|
+
Reports are already in the target's history, so a restart loses the wake-up, not
|
|
118
|
+
the message.
|
|
119
|
+
|
|
120
|
+
### Changed
|
|
121
|
+
|
|
122
|
+
- Instruction sequence and back-pressure are counted per **actual** target, so
|
|
123
|
+
`target="rgb"` and `target="rgb.2"` cannot be used to sneak a second instruction
|
|
124
|
+
past the guard.
|
|
125
|
+
- `bus_list` no longer lists a role twice after a session re-registers.
|
|
126
|
+
- Documentation rewritten around the three-level model.
|
|
127
|
+
|
|
128
|
+
## 0.1.1 — 2026-10-19
|
|
129
|
+
|
|
130
|
+
### Fixed — instructions piling up
|
|
131
|
+
|
|
132
|
+
Each delivery becomes its own turn in the target conversation, so a burst of `指令`
|
|
133
|
+
made the target execute obsolete commands one after another while the task file had
|
|
134
|
+
already moved on. Two changes:
|
|
135
|
+
|
|
136
|
+
- **Instruction back-pressure**: a second `指令` to the same target is refused with
|
|
137
|
+
`instruction_pending` while the previous one has no `回报` yet. Update the task file
|
|
138
|
+
and wait for the report, or pass `force: true`. Bounded by `pendingInstructionTtlSec`
|
|
139
|
+
(default 600s), so a target that never reports does not block forever. Notices and
|
|
140
|
+
requests are not affected.
|
|
141
|
+
- **Self-identifying messages**: every `指令` footer now carries its per-pair sequence
|
|
142
|
+
("本线第 N 条指令") and tells the receiver to compare it with `bus_list.latest`; a
|
|
143
|
+
message whose `seq` is below the latest is stale and should not be executed.
|
|
144
|
+
|
|
145
|
+
`bus_list` additionally returns `latest`: the newest instruction per role pair, with
|
|
146
|
+
`seq`, `ref`, and `awaitingReport`. Both the counter and the pending state are rebuilt
|
|
147
|
+
from `deliveries.jsonl`, so they survive a client restart without another state file.
|
|
148
|
+
|
|
149
|
+
New config keys: `blockSecondInstruction` (default `true`), `pendingInstructionTtlSec`
|
|
150
|
+
(default `600`). New `bus_send` parameter: `force`.
|
|
151
|
+
|
|
152
|
+
### Fixed — documentation
|
|
153
|
+
|
|
154
|
+
- `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.
|
|
155
|
+
- `bus_send` tool description documents the subagent restriction and that `ref` is passed through verbatim.
|
|
156
|
+
- The protocol line appended to `指令` messages now suggests `R-` numbering (e.g. `R-001`) for reports.
|
|
157
|
+
- README (both languages): documented the subagent restriction, the `ref` convention, the instruction back-pressure, and a token-budget section.
|
|
158
|
+
|
|
3
159
|
## 0.1.0 — 2026-10-18
|
|
4
160
|
|
|
5
161
|
- Initial public release.
|
package/README.md
CHANGED
|
@@ -6,30 +6,38 @@ A small host plugin for [DeepSeek Harness](https://www.deepseek.com/harness/) (D
|
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
9
|
-
- ✅ Main →
|
|
10
|
-
- ✅
|
|
11
|
-
- ✅
|
|
9
|
+
- ✅ Main → line: `bus_send(target="rgb", kind="指令", done="…", ...)` — write only the line name and it lands on whichever conversation under that line is alive
|
|
10
|
+
- ✅ Line → main: `bus_send(target="main", kind="回报", ...)`
|
|
11
|
+
- ✅ Three levels: `main` / line `rgb` / temporary conversations `rgb.1`, `rgb.2`; replacing a conversation never changes what the main conversation writes
|
|
12
12
|
- ✅ Scoped by session cwd: each workspace gets its own independent bus
|
|
13
|
+
- ✅ Remembers three things for you: a start-up briefing in every new conversation, a context-watermark notice at 40%, and a refusal to unregister without writing the ledger
|
|
13
14
|
|
|
14
15
|
## What it does not do
|
|
15
16
|
|
|
16
|
-
- ❌
|
|
17
|
+
- ❌ **It does not create conversations.** You open them in the GUI; the plugin only registers and routes.
|
|
18
|
+
- ❌ **It does not close conversations.** It gives you the signal (`suggestHandoff`); closing is your call.
|
|
19
|
+
- ❌ **It does not compress context.** Compaction is triggered by the host; the plugin can neither read the water level nor change the threshold.
|
|
20
|
+
- ❌ No file watching, no polling, no autonomous work when you are away
|
|
17
21
|
- ❌ 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
22
|
- ❌ No cross-workspace addressing (one bus per workspace)
|
|
23
|
+
- ❌ 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
24
|
|
|
20
25
|
## How it works
|
|
21
26
|
|
|
22
27
|
```
|
|
23
|
-
you ──open the main conversation──▶ main (bus_register main)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
you ──open the main conversation──▶ main (bus_register role="main")
|
|
29
|
+
│
|
|
30
|
+
│ 1. write task files / instruction files
|
|
31
|
+
│ 2. bus_send(target="rgb", kind="指令", done="…", message="…")
|
|
32
|
+
▼
|
|
33
|
+
rgb line ──▶ rgb.1 (bus_register parent="rgb") ← woken
|
|
34
|
+
│ 3. do the work, write the report file
|
|
35
|
+
│ 4. bus_send(target="main", kind="回报", ref="R-…")
|
|
36
|
+
▼
|
|
37
|
+
main (woken once per few reports)
|
|
38
|
+
│ 5. this task is done: unregister rgb.1, open rgb.2 for the next one
|
|
39
|
+
▼
|
|
40
|
+
rgb.2 (bus_register parent="rgb" replaces="rgb.1")
|
|
33
41
|
```
|
|
34
42
|
|
|
35
43
|
Keep the bus message short: summary + file pointers. The authoritative content stays in your files.
|
|
@@ -37,9 +45,11 @@ Keep the bus message short: summary + file pointers. The authoritative content s
|
|
|
37
45
|
## Requirements
|
|
38
46
|
|
|
39
47
|
- A DSH **web/desktop** profile with the Host session controller and session store. Headless/SDK-only profiles may not provide them; the plugin then stays inactive (it declares `inject`).
|
|
40
|
-
- Tested on DSH `0.2.0-rc.x` (desktop, Windows). Host services such as `sessionController.resolveAgent`, `agent.followup/inject`, `sessions.flush`,
|
|
48
|
+
- 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.
|
|
41
49
|
- No npm dependencies. The plugin imports only Node builtins.
|
|
42
|
-
-
|
|
50
|
+
- 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
|
+
- **No session creation or closing**: both are GUI-only actions.
|
|
43
53
|
|
|
44
54
|
## Install
|
|
45
55
|
|
|
@@ -68,19 +78,197 @@ dsh plugin --profile <profile> add dsh-handoff-bus
|
|
|
68
78
|
## Quick start
|
|
69
79
|
|
|
70
80
|
1. Main conversation: `bus_register(role="main")`
|
|
71
|
-
2. Each sub-conversation: `bus_register(
|
|
72
|
-
3. `bus_list` shows the role
|
|
73
|
-
4. Main →
|
|
74
|
-
5.
|
|
81
|
+
2. Each sub-conversation: `bus_register(parent="rgb")` → gets `rgb.1`
|
|
82
|
+
3. `bus_list` shows the role tree and where each line would route
|
|
83
|
+
4. Main → line: `bus_send(target="rgb", kind="指令", done="…", message="…")`
|
|
84
|
+
5. Line → main: `bus_send(target="main", kind="回报", ref="R-001", message="…")`
|
|
75
85
|
|
|
76
|
-
Role names are
|
|
86
|
+
Role names are lowercase letters, digits, dots, underscores and hyphens, at most three levels (`main` / `rgb` / `rgb.1` / `rgb.1.worker`).
|
|
87
|
+
|
|
88
|
+
`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.
|
|
77
89
|
|
|
78
90
|
| Situation | Call |
|
|
79
91
|
|---|---|
|
|
80
|
-
| Main gives work to a
|
|
92
|
+
| Main gives work to a line | `bus_send(target="rgb", kind="指令", done="pytest tests/test_rgb.py passes", message="…")` |
|
|
81
93
|
| Sub reports back | `bus_send(target="main", kind="回报", ref="R-…", message="…")` |
|
|
82
|
-
|
|
|
83
|
-
|
|
|
94
|
+
| Stuck, main must act now | same, plus `urgent: true` |
|
|
95
|
+
| Main asks for status (no work) | `bus_send(target="rgb", kind="请求", ref="Q-…", message="Status only, do not start work.")` |
|
|
96
|
+
| Queue without waking | add `wake: false` |
|
|
97
|
+
|
|
98
|
+
## Three-level roles
|
|
99
|
+
|
|
100
|
+
| Level | Examples | Who maintains it | Rotate? |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| 1 main | `main` | Registered once by you in the main conversation | No. There is exactly one per bus |
|
|
103
|
+
| 2 line | `rgb`, `world`, `scene`, `reward` | Appears automatically as a *virtual node* the first time a child registers under it | **No.** It is only a name; no session is attached |
|
|
104
|
+
| 3 temporary conversation | `rgb.1`, `rgb.2` | Numbered by the plugin when a new conversation registers | **Yes**: one task per conversation, unregister when done, open a new one next time |
|
|
105
|
+
|
|
106
|
+
### Registering
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
bus_register(parent="rgb") → role: "rgb.1"
|
|
110
|
+
bus_register(parent="rgb", note="calibration") → role: "rgb.2"
|
|
111
|
+
bus_register(role="main") → the main line
|
|
112
|
+
bus_register(role="rgb") → one conversation owns the line itself (optional)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
All you say in each new sub-conversation:
|
|
116
|
+
|
|
117
|
+
> Take work on the rgb line: call `bus_register(parent="rgb")` first, then read `handoff/rgb/STATE.md`.
|
|
118
|
+
|
|
119
|
+
### How a target is resolved
|
|
120
|
+
|
|
121
|
+
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")`.
|
|
124
|
+
|
|
125
|
+
The result carries `routedFrom` / `routedTo`, so nothing is a black box. `bus_list.routeTo` tells you where it would land before you send.
|
|
126
|
+
|
|
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.
|
|
128
|
+
|
|
129
|
+
### Handing a line over
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
bus_register(parent="rgb", replaces="rgb.1")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The old conversation moves to `retired` (kept for `retiredRetentionDays`, default 7, so a "why can't I send here" question has an answer). If it still had an unanswered instruction, the result names that `ref` and tells the new conversation to **read the ledger first instead of redoing the work from scratch**.
|
|
136
|
+
|
|
137
|
+
### When to archive a temporary conversation
|
|
138
|
+
|
|
139
|
+
All three at once:
|
|
140
|
+
|
|
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).
|
|
144
|
+
|
|
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.
|
|
146
|
+
|
|
147
|
+
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
|
+
|
|
149
|
+
## Instructions must carry an acceptance test
|
|
150
|
+
|
|
151
|
+
`bus_send(kind="指令")` without `done` is refused (`instruction_incomplete`):
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
bus_send(target="rgb", kind="指令",
|
|
155
|
+
message="Wire the RGB camera calibration into the data loader",
|
|
156
|
+
done="calibration file is read; all three cameras align on the same frame id; pytest tests/test_rgb.py passes")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`done` is echoed in the delivered message so the report can be checked against it. This removes the most common waste: an instruction without an acceptance test makes the sub guess, a wrong guess produces a follow-up instruction, and every follow-up costs the target a whole context reload.
|
|
160
|
+
|
|
161
|
+
Disable with `requireDone: false`.
|
|
162
|
+
|
|
163
|
+
## Report digest and `urgent`
|
|
164
|
+
|
|
165
|
+
Not every report deserves a wake-up — the main conversation usually holds the longest context, so its wake-ups are the most expensive.
|
|
166
|
+
|
|
167
|
+
| Case | Default |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `kind="回报"` without `urgent` | **No wake.** The message goes into the main conversation's history; after `digestMinCount` reports (default 3) or `digestMaxWaitSec` (default 900s), one summary message wakes it once |
|
|
170
|
+
| `kind="回报"` + `urgent: true` | Wakes immediately |
|
|
171
|
+
| `kind="请求"` | Urgent by default (a request is a decision ask) |
|
|
172
|
+
| `wake: true` / `wake: false` | Explicit override, highest priority |
|
|
173
|
+
|
|
174
|
+
Only three things deserve `urgent`: **stuck**, **failed and needs a new approach**, **needs a decision**. "Done", "50%", "here is the artifact path" do not.
|
|
175
|
+
|
|
176
|
+
An urgent message folds any queued backlog into the same wake-up (the text says "N earlier reports are already queued") instead of waking twice. Urgent deliveries still obey the rate limits.
|
|
177
|
+
|
|
178
|
+
Implementation note: a batched report is **injected into the history without waking**, so a client restart loses the wake-up, never the message. The single timer exists only while a digest is pending and is `unref`'d.
|
|
179
|
+
|
|
180
|
+
Disable with `digestReports: false` (back to waking on every report).
|
|
181
|
+
|
|
182
|
+
## Delivering without piling up
|
|
183
|
+
|
|
184
|
+
Every delivery is a separate turn in the target conversation. If `main` sends three
|
|
185
|
+
`指令` to `worker-a` in a row while `worker-a` is still working, all three queue up and
|
|
186
|
+
are executed in order — the first one runs against a task file that is already at
|
|
187
|
+
version three. Three consequences:
|
|
188
|
+
|
|
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.
|
|
192
|
+
- **Each `指令` carries its own sequence** ("本线第 3 条指令") and the footer tells the
|
|
193
|
+
receiver to compare it with `bus_list.latest`. A `seq` below the latest means the
|
|
194
|
+
message was superseded: do the newest one, skip the duplicate work.
|
|
195
|
+
- **The counter and the pending state are rebuilt from `deliveries.jsonl`**, so a client
|
|
196
|
+
restart neither resets the numbering nor forgets an unanswered instruction.
|
|
197
|
+
- **Numbering follows the actual target**, so `target="rgb"` and `target="rgb.2"` landing
|
|
198
|
+
on the same conversation share one sequence and cannot be used to bypass the guard.
|
|
199
|
+
|
|
200
|
+
A sub-conversation prompt that makes this reliable:
|
|
201
|
+
|
|
202
|
+
```text
|
|
203
|
+
收到 [BUS 指令] 后,先调用 bus_list 看 latest 里「main->自己角色」的 seq。
|
|
204
|
+
如果消息末尾写的「本线第 N 条」小于 seq,说明已被后面的指令取代:
|
|
205
|
+
只做最新那条,不要重复劳动,并在回报里说明跳过了哪一条。
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Context budget and start-up briefing
|
|
209
|
+
|
|
210
|
+
The longer a conversation lives, the more every request costs. The three things
|
|
211
|
+
below are what a person would otherwise have to remember; the plugin enforces them.
|
|
212
|
+
|
|
213
|
+
| When | What happens | Default |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| Before a conversation's first request | A **briefing** is injected: which handoff files to read, and how to read the big ones | `briefing: true` |
|
|
216
|
+
| Context crosses the watermark | A **budget** notice: finish the current task, write the ledger, tell the user to switch conversations | 40% warn / 70% stop |
|
|
217
|
+
| Unregistering a role | Refused when the handoff directory has not been written lately (`handover_missing`) | 30 minutes |
|
|
218
|
+
|
|
219
|
+
All three **append one user message to the request that was going to happen
|
|
220
|
+
anyway** — no extra turn, no extra model call. Each costs 100–200 tokens and saves
|
|
221
|
+
the tens of thousands that reading a 95 KB ledger in full would cost on the very
|
|
222
|
+
first request.
|
|
223
|
+
|
|
224
|
+
A briefing looks like this (only when `briefingFiles` / `briefingScan` match real
|
|
225
|
+
files):
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
[BUS 开工简报] 本工作区有 _HANDOFF/ 协作目录:D:\project\_HANDOFF
|
|
229
|
+
开工顺序(先读完再动手;大文件不要整篇读):
|
|
230
|
+
1. 全文读:_HANDOFF/README.md、_HANDOFF/01-铁律.md
|
|
231
|
+
2. 只读结构与末尾:_HANDOFF/41-回报-B线.md —— 先 grep -n '^## ' 看标题,再 tail -n 80 看最新回执;
|
|
232
|
+
3. 要细节再按标题定位:sed -n 'A,Bp' 文件;不要 cat 整篇,也不要把大段日志贴进对话。
|
|
233
|
+
收工规矩:把状态写回 _HANDOFF/(含未回报的指令 ref 与产物路径),否则 bus_unregister 会被拒。
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The estimate is derived from the prompt text (ASCII ≈ 3.8 chars/token, CJK ≈ 1.6)
|
|
237
|
+
plus `contextOverheadTokens` for the system prompt and tool definitions. It is an
|
|
238
|
+
estimate within roughly 15%, not the host's exact number.
|
|
239
|
+
|
|
240
|
+
`contextWindowTokens` defaults to 524288, not the declared 1048576: in practice
|
|
241
|
+
`context overflow` starts around 430k, so 40% of the declared window would fire
|
|
242
|
+
after it is already too late.
|
|
243
|
+
|
|
244
|
+
## Token cost
|
|
245
|
+
|
|
246
|
+
The bus itself is cheap; the turns it triggers are not.
|
|
247
|
+
|
|
248
|
+
| Item | Cost |
|
|
249
|
+
|---|---|
|
|
250
|
+
| Tool schemas (5 tools) | ~1400 tokens per request, in every conversation, used or not |
|
|
251
|
+
| Message wrapper | ~120 tokens per delivered message (header, workspace, time, protocol lines) plus a few dozen for `done` |
|
|
252
|
+
| **A woken turn** | **the target's whole context, re-sent as input** |
|
|
253
|
+
|
|
254
|
+
The third row dominates. Waking a conversation that already holds 30k tokens of history
|
|
255
|
+
costs 30k input tokens for a 20-character message — and the message stays in that
|
|
256
|
+
history, so every later turn of that session is slightly more expensive as well.
|
|
257
|
+
|
|
258
|
+
Since 0.3.0 the plugin remembers items 1 and 3 for you (briefing, budget notice,
|
|
259
|
+
handover gate); nobody has to watch it.
|
|
260
|
+
|
|
261
|
+
Ways to spend less, best first:
|
|
262
|
+
|
|
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.
|
|
264
|
+
2. **Stop pasting logs into the conversation**: write them to a file, grep the hits, paste only those lines.
|
|
265
|
+
3. **Make `done` specific** — it removes the "one more thing" follow-up that costs a whole context reload.
|
|
266
|
+
4. **Reserve `urgent` for stuck/failed/decision** — everything else rides the digest.
|
|
267
|
+
5. **`wake: false`** for anything that is not a "start working now" signal.
|
|
268
|
+
6. **Use `kind="通知"` for state sync**: it lands on disk and costs nothing.
|
|
269
|
+
7. **Never poll.** A main conversation calling `bus_list` every few minutes re-reads the most expensive context and learns nothing.
|
|
270
|
+
8. **Pointers, not payloads.** Summary + file path in the body; the report stays in the file.
|
|
271
|
+
9. **Disable the plugin in profiles that never use it** — that removes the ~1400-token schema.
|
|
84
272
|
|
|
85
273
|
## New workspace setup (copy-paste prompt)
|
|
86
274
|
|
|
@@ -109,22 +297,23 @@ Step 2 — register first, then verify:
|
|
|
109
297
|
bus_list
|
|
110
298
|
|
|
111
299
|
Step 3 — to dispatch, write the task/instruction file first, then call:
|
|
112
|
-
bus_send(target="
|
|
300
|
+
bus_send(target="rgb", kind="指令", ref="D-001",
|
|
301
|
+
done="report-a.md is written and pytest tests/test_rgb.py passes",
|
|
113
302
|
message="Task file task-a.md is updated. Read it and D-001; when done write report-a.md and bus_send back to main.")
|
|
114
303
|
|
|
115
304
|
Step 4 — sub-conversations only write their own status/report files;
|
|
116
305
|
handoff/.bus/ is the bus state (bindings.json / deliveries.jsonl) — do not edit it.
|
|
117
306
|
|
|
118
|
-
Create the files, register main, then show me the role
|
|
307
|
+
Create the files, register main, then show me the role tree with bus_list.
|
|
119
308
|
```
|
|
120
309
|
|
|
121
|
-
Sub-conversation prompt:
|
|
310
|
+
Sub-conversation prompt (send this at the start of every new conversation on a line):
|
|
122
311
|
|
|
123
312
|
```text
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
313
|
+
You are a new conversation on the rgb line. First call bus_register(parent="rgb") to get your number,
|
|
314
|
+
then read handoff/rgb/STATE.md for progress. Only write your own status/report files under handoff/rgb/.
|
|
315
|
+
When done call bus_send(target="main", kind="回报", ref="R-<date>-01", message="Report written; conclusion: …; next: …").
|
|
316
|
+
Add urgent: true when stuck, failed, or you need a decision.
|
|
128
317
|
```
|
|
129
318
|
|
|
130
319
|
## Multiple workspaces
|
|
@@ -140,13 +329,21 @@ bus_send(target="main", kind="回报", ref="R-<date>-01", message="Report writte
|
|
|
140
329
|
|
|
141
330
|
| Tool | Purpose |
|
|
142
331
|
|---|---|
|
|
143
|
-
| `bus_register(role
|
|
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 |
|
|
144
334
|
| `bus_whoami()` | Current role + workspace |
|
|
145
|
-
| `bus_list()` |
|
|
146
|
-
| `bus_send(target, message, kind?, ref?, wake?)` | Deliver to a role
|
|
335
|
+
| `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` |
|
|
147
337
|
|
|
148
338
|
`kind`: `指令` (instruction), `回报` (report), `通知` (notice), `请求` (request). These four values are literal tokens — keep them in Chinese; do not translate them.
|
|
149
339
|
|
|
340
|
+
Every result except `bus_unregister` carries `context` (estimated `pct` / `tokens` / `level`);
|
|
341
|
+
`bus_register` / `bus_whoami` / `bus_list` also carry `handoff` (directory, entry files, minutes
|
|
342
|
+
since the last write); `bus_send` adds `targetContext` and warns when the target is already past
|
|
343
|
+
the watermark — archive it instead of piling on more work.
|
|
344
|
+
|
|
345
|
+
Wake policy when `wake` is omitted: `指令` / `通知` wake immediately; `回报` goes to the digest without waking; `请求` wakes immediately. An explicit `wake` always wins.
|
|
346
|
+
|
|
150
347
|
## Configuration
|
|
151
348
|
|
|
152
349
|
Configuration is read in this order (later wins):
|
|
@@ -163,7 +360,29 @@ Example `config.json`:
|
|
|
163
360
|
"stateDirName": ".bus",
|
|
164
361
|
"maxMessageChars": 4000,
|
|
165
362
|
"maxSendsPerHour": 30,
|
|
166
|
-
"maxPairSendsPer10Min": 8
|
|
363
|
+
"maxPairSendsPer10Min": 8,
|
|
364
|
+
"blockSecondInstruction": true,
|
|
365
|
+
"pendingInstructionTtlSec": 600,
|
|
366
|
+
"requireDone": true,
|
|
367
|
+
"routeMaxAttempts": 3,
|
|
368
|
+
"retiredRetentionDays": 7,
|
|
369
|
+
"digestReports": true,
|
|
370
|
+
"digestMinCount": 3,
|
|
371
|
+
"digestMaxWaitSec": 900,
|
|
372
|
+
"handoffAfterMessages": 40,
|
|
373
|
+
"handoffAfterIdleMin": 30,
|
|
374
|
+
"briefing": true,
|
|
375
|
+
"briefingFiles": ["README.md", "PROTOCOL.md"],
|
|
376
|
+
"briefingScan": [],
|
|
377
|
+
"briefingScanLimit": 4,
|
|
378
|
+
"briefingTailLines": 80,
|
|
379
|
+
"contextBudget": true,
|
|
380
|
+
"contextWindowTokens": 524288,
|
|
381
|
+
"contextWarnRatio": 0.4,
|
|
382
|
+
"contextStopRatio": 0.7,
|
|
383
|
+
"contextOverheadTokens": 40000,
|
|
384
|
+
"requireHandoverOnLeave": true,
|
|
385
|
+
"handoverWindowMin": 30
|
|
167
386
|
}
|
|
168
387
|
```
|
|
169
388
|
|
|
@@ -177,6 +396,28 @@ Example `config.json`:
|
|
|
177
396
|
| `maxMessageChars` | 4000 | Per-message limit |
|
|
178
397
|
| `maxSendsPerHour` | 30 | Per-conversation hourly limit |
|
|
179
398
|
| `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
|
+
| `requireDone` | `true` | `kind="指令"` must carry `done` |
|
|
402
|
+
| `routeMaxAttempts` | 3 | Candidates probed when `target` is a line name |
|
|
403
|
+
| `retiredRetentionDays` | 7 | How long retired roles are remembered |
|
|
404
|
+
| `digestReports` | `true` | Batch plain reports (disable to wake on every report) |
|
|
405
|
+
| `digestMinCount` | 3 | Reports per digest wake-up |
|
|
406
|
+
| `digestMaxWaitSec` | 900 | Longest a report waits before the digest fires |
|
|
407
|
+
| `handoffAfterMessages` | 40 | `suggestHandoff` threshold: messages exchanged |
|
|
408
|
+
| `handoffAfterIdleMin` | 30 | `suggestHandoff` threshold: minutes idle |
|
|
409
|
+
| `briefing` | `true` | Inject the start-up briefing before the first request of a conversation |
|
|
410
|
+
| `briefingFiles` | `["README.md","PROTOCOL.md"]` | Files to read in full, relative to the coordination directory |
|
|
411
|
+
| `briefingScan` | `[]` | Globs read as structure + tail, e.g. `["4*-*.md"]` |
|
|
412
|
+
| `briefingScanLimit` | 4 | How many matching ledgers to name (newest first) |
|
|
413
|
+
| `briefingTailLines` | 80 | Lines in the `tail -n` suggested by the briefing |
|
|
414
|
+
| `contextBudget` | `true` | Context watermark notices |
|
|
415
|
+
| `contextWindowTokens` | 524288 | What counts as 100% (not the declared 1048576) |
|
|
416
|
+
| `contextWarnRatio` | 0.4 | Warn: finish the current task, write the ledger, switch conversations |
|
|
417
|
+
| `contextStopRatio` | 0.7 | Demand an immediate wrap-up |
|
|
418
|
+
| `contextOverheadTokens` | 40000 | Fixed cost of the system prompt and tool definitions |
|
|
419
|
+
| `requireHandoverOnLeave` | `true` | Refuse `bus_unregister` when the coordination directory is stale |
|
|
420
|
+
| `handoverWindowMin` | 30 | Window for the check above, in minutes |
|
|
180
421
|
|
|
181
422
|
`deliveries.jsonl` is append-only. Delete it manually if it grows; automatic rotation is planned for a later release.
|
|
182
423
|
|
|
@@ -188,10 +429,15 @@ Example `config.json`:
|
|
|
188
429
|
| `no_workspace` | The conversation has no cwd |
|
|
189
430
|
| `invalid_role` / `invalid_target` / `invalid_message` / `invalid_kind` | Invalid argument |
|
|
190
431
|
| `caller_not_registered` | This conversation has no role yet |
|
|
191
|
-
| `target_not_registered` | The target role does not exist in this workspace |
|
|
432
|
+
| `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 |
|
|
434
|
+
| `role_not_found` / `role_virtual` / `not_owner` | `bus_unregister`: no such role / it is a virtual line node / it belongs to another conversation |
|
|
192
435
|
| `self_target` | The target is this conversation |
|
|
193
|
-
| `target_unresolvable` | The target session cannot be opened (archived/deleted) |
|
|
436
|
+
| `target_unresolvable` | The target session cannot be opened (archived/deleted, or owned by subagent routing) |
|
|
194
437
|
| `rate_limited` | Rate limit hit |
|
|
438
|
+
| `instruction_pending` | A `指令` to this target is still unanswered; wait for the `回报`, or pass `force: true` |
|
|
439
|
+
| `instruction_incomplete` | `kind="指令"` is missing `done` |
|
|
440
|
+
| `handover_missing` | The coordination directory was not written before leaving; write the state, or pass `force: true` |
|
|
195
441
|
| `flush_failed` | Queued but persistence was not acknowledged |
|
|
196
442
|
| `delivery_failed` | Delivery error |
|
|
197
443
|
| `aborted` | The tool call was cancelled |
|