dsh-handoff-bus
English | 中文
A small host plugin for DeepSeek Harness (DSH): it lets the main conversation and the sub-conversations you open yourself send instructions and reports to each other directly, so you no longer have to switch windows and relay messages.
What it does
- ✅ Main → line:
bus_send(target="api", kind="指令", done="…", ...) — write only the line name and it lands on whichever conversation under that line is alive
- ✅ Line → main:
bus_send(target="main", kind="回报", ...)
- ✅ Three levels:
main / line api / temporary conversations api.1, api.2; replacing a conversation never changes what the main conversation writes
- ✅ Scoped by session cwd: each workspace gets its own independent bus
- ✅ 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
What it does not do
- ❌ It does not create conversations. You open them in the GUI; the plugin only registers and routes.
- ❌ It does not close conversations. It gives you the signal (
suggestHandoff); closing is your call.
- ❌ It does not compress context. Compaction is triggered by the host; the plugin can neither read the water level nor change the threshold.
- ❌ No file watching, no polling, no autonomous work when you are away
- ❌ 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
- ❌ No cross-workspace addressing (one bus per workspace)
- ❌ 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
How it works
you ──open the main conversation──▶ main (bus_register role="main")
│
│ 1. write task files / instruction files
│ 2. bus_send(target="api", kind="指令", done="…", message="…")
▼
api line ──▶ api.1 (bus_register parent="api") ← woken
│ 3. do the work, write the report file
│ 4. bus_send(target="main", kind="回报", ref="R-…")
▼
main (woken once per few reports)
│ 5. api.1 stays and waits; only when its context is full
│ (or the user is done) does it unregister, and api.2 takes over
▼
api.2 (bus_register parent="api")
Keep the bus message short: summary + file pointers. The authoritative content stays in your files.
Requirements
- 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).
- 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.
- No npm dependencies. The plugin imports only Node builtins.
- Tool result messages and relayed message bodies are in Chinese; error codes are stable English identifiers. Role names are lowercase ASCII with dots (
main, api, api.1).
- 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.
- No session creation or closing: both are GUI-only actions.
Install
From npm (recommended, once published)
- DSH sidebar → Plugins → Add plugin → search for
dsh-handoff-bus
- Install, then Enable
- Fully quit the client (including the tray) and reopen it — this is a host plugin
Or:
dsh plugin --profile <profile> add dsh-handoff-bus
The CLI command targets a CLI-booted profile. In the desktop app the profile is managed by the app itself, so install through the Plugins page there.
From source
- Clone this repository.
- Plugins page → Add plugin → enter the absolute path of the repository directory.
- Enable, then fully quit and reopen the client.
A linked plugin lives outside the profile's node_modules tree and therefore cannot resolve @deepseek-ai/*. This package intentionally imports only Node builtins, so both install methods work.
Quick start
- Main conversation:
bus_register(role="main")
- Each sub-conversation:
bus_register(parent="api") → gets api.1
bus_list shows the role tree and where each line would route
- Main → line:
bus_send(target="api", kind="指令", done="…", message="…")
- Line → main:
bus_send(target="main", kind="回报", ref="R-001", message="…")
Role names are lowercase letters, digits, dots, underscores and hyphens, at most three levels (main / api / api.1 / api.1.worker).
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.
| Situation | Call |
|---|
| Main gives work to a line | bus_send(target="api", kind="指令", done="npm test passes", message="…") |
| Sub reports back | bus_send(target="main", kind="回报", ref="R-…", message="…") |
| Stuck, main must act now | same, plus urgent: true |
| Main asks for status (no work) | bus_send(target="api", kind="请求", ref="Q-…", message="Status only, do not start work.") |
| Queue without waking | add wake: false |
Three-level roles
| Level | Examples | Who maintains it | Rotate? |
|---|
| 1 main | main | Registered once by you in the main conversation | No. There is exactly one per bus |
| 2 line | api, web, docs, data | Appears automatically as a virtual node the first time a child registers under it | No. It is only a name; no session is attached |
| 3 temporary conversation | api.1, api.2 | Numbered by the plugin when a new conversation registers | Yes: one task per conversation, unregister when done, open a new one next time |
Registering
bus_register(parent="api") → role: "api.1"
bus_register(parent="api", note="calibration") → role: "api.2"
bus_register(role="main") → the main line
bus_register(role="api") → same as parent="api": you get api.N (see below)
All you say in each new sub-conversation:
注册 api
The plugin sends that conversation a fixed three-step note on its first request
(bus_register(parent="api") → read handoff/api/ → report), so the model never
needs to know which number is next, nor guess between role and parent.
A line name never becomes a conversation. role="api" used to bind the line
itself, after which routing short-circuited to it and the numbered conversations
never received anything. A bare line name passed as role is now coerced to
parent, and the result says so via coercedFrom.
Registration also tells you where you stand: if a smaller-numbered conversation
is already on the line, you are a substitute and should stand by. That is
stated, not enforced — the queue order is untouched, the lowest number always
wins.
How a target is resolved
target="api.2" → that exact conversation, no guessing.
target="api" → the line queue: candidates are the conversations that are
not retired and hold no unfinished instruction, sorted by number, and the
lowest number wins. Each is opened in turn until one resolves (at most
routeMaxAttempts, default 3).
- Nobody usable →
line_busy (there are conversations, all working) or
no_available_session (all retired, or the line is empty).
The result carries routedFrom / routedTo, so nothing is a black box. bus_list.routeTo tells you where it would land before you send.
A line owns one instruction at a time. A bench is not a parallel pool: while
api.1 owes a 回报, api.2 does not sneak in — the send is refused, and naming
api.2 explicitly does not help either. There is no force; the only override
is preempt: true, which injects "stop and save, then switch" into the running
turn and cancels the displaced instruction.
Handing a line over
bus_register(parent="api", replaces="api.1")
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.
When to archive a temporary conversation
The bus now decides this from measured context (compaction count first, water
level second) and marks the conversation retired when it is idle:
| advise | stop taking work |
|---|
| compactions | ≥ 1 | ≥ 2 |
| water level | > 50% | > 70% |
A retired conversation keeps its context and its place in the tree; routing simply
skips it. The bus does not lecture the user from the outside — it drops one line
into the retired conversation's own history, and that conversation tells its own
window: "archive me, the next one will be api.4".
suggestHandoff still exists as a softer hint (40 messages, or 30 minutes idle)
for conversations that are merely stale rather than full. bus_list also exposes
compactions and contextPct once a conversation has been measured.
When the main conversation has to go
The main conversation fills up too, but its line is wider: **≥ 2 compactions or
85%**. Stopping it stops the whole workspace, so the cost is not the same as
swapping a temporary conversation.
It is never cut short. It drains:
- it stops issuing instructions (
draining is returned) and keeps receiving reports;
- no line is interrupted — they finish at their own pace;
- a background sweep checks every 900s whether every line is idle, backing off to
1 hour after 3 quiet rounds, because a long task does not deserve to be poked;
- the moment everything is idle, it says: write the ledger, archive me, open a new
main and say "take main".
A new main calling bus_register(role="main") takes the name over; child bindings
and numbers are untouched, and their reports land on the new main automatically.
Being compacted while draining is harmless — at that point it only has to remember
one word: wait.
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.
Instructions must carry an acceptance test
bus_send(kind="指令") without done is refused (instruction_incomplete):
bus_send(target="api", kind="指令",
message="Split the export logic into its own module",
done="npm test passes; the exported bytes are identical to the old implementation")
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.
Disable with requireDone: false.
Report digest and urgent
Not every report deserves a wake-up — the main conversation usually holds the longest context, so its wake-ups are the most expensive.
| Case | Default |
|---|
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 |
kind="回报" + urgent: true | Wakes immediately |
kind="请求" | Urgent by default (a request is a decision ask) |
wake: true / wake: false | Explicit override, highest priority |
Only three things deserve urgent: stuck, failed and needs a new approach, needs a decision. "Done", "50%", "here is the artifact path" do not.
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.
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.
Disable with digestReports: false (back to waking on every report).
Delivering without piling up
Every delivery is a separate turn in the target conversation. If main sends three
指令 to worker-a in a row while worker-a is still working, all three queue up and
are executed in order — the first one runs against a task file that is already at
version three. Three consequences:
- A second
指令 is refused while the line still owes a 回报 (line_busy).
Update the task file, wait for the report, then dispatch again. There is no
force and no timeout: a long task keeps the line. To take the line anyway,
preempt: true — it injects into the running turn, orders "stop and save the
half-done work", and cancels what it displaced. A late report for the cancelled
ref is recorded but never frees the line.
- Each
指令 carries its own sequence ("本线第 3 条指令") and the footer tells the
receiver to compare it with bus_list.latest. A seq below the latest means the
message was superseded: do the newest one, skip the duplicate work.
- The counter and the pending state are rebuilt from
deliveries.jsonl, so a client
restart neither resets the numbering nor forgets an unanswered instruction.
- Numbering follows the actual target, so
target="api" and target="api.2" landing
on the same conversation share one sequence and cannot be used to bypass the guard.
A sub-conversation prompt that makes this reliable:
收到 [BUS 指令] 后,先调用 bus_list 看 latest 里「main->自己角色」的 seq。
如果消息末尾写的「本线第 N 条」小于 seq,说明已被后面的指令取代:
只做最新那条,不要重复劳动,并在回报里说明跳过了哪一条。
Context budget and start-up briefing
The longer a conversation lives, the more every request costs. The three things
below are what a person would otherwise have to remember; the plugin enforces them.
| When | What happens | Default |
|---|
| Before a conversation's first request | A briefing is injected: which handoff files to read, and how to read the big ones | briefing: true |
| Context crosses the watermark | A budget notice: finish the current task, write the ledger, tell the user to switch conversations | 40% warn / 70% stop |
| Unregistering a role | Refused when the handoff directory has not been written lately (handover_missing) | 30 minutes |
All three append one user message to the request that was going to happen
anyway — no extra turn, no extra model call. Each costs 100–200 tokens and saves
the tens of thousands that reading a 95 KB ledger in full would cost on the very
first request.
A briefing looks like this (only when briefingFiles / briefingScan match real
files):
[BUS 开工简报] 本工作区有 handoff/ 协作目录:<workspace>/handoff
开工顺序(先读完再动手;大文件不要整篇读):
1. 全文读:handoff/README.md、handoff/PROTOCOL.md
2. 只读结构与末尾:handoff/a-report.md —— 先 grep -n '^## ' 看标题,再 tail -n 80 看最新回执;
3. 要细节再按标题定位:sed -n 'A,Bp' 文件;不要 cat 整篇,也不要把大段日志贴进对话。
状态规矩:写回 handoff/<你的角色>.md(.md,不是 .txt;含未回报的指令 ref 与产物路径)。不收工不要注销,做完回报后待命即可。
The estimate is derived from the prompt text (ASCII ≈ 3.8 chars/token, CJK ≈ 1.6)
plus contextOverheadTokens for the system prompt and tool definitions. It is an
estimate within roughly 15%, not the host's exact number.
The estimate is only the fallback. For a registered conversation, the context
in a tool result is the same measurement the retirement decision uses (the host's
projection buckets plus the compaction count); only when the host cannot be probed
does it fall back to the character estimate, flagged estimated: true. Two
different numbers would be worse than one imprecise number: bus_list once read
7.6% (the 40,000 constant over an empty message list) while the plugin was already
treating the same session as 78%.
contextWindowTokens defaults to 524288, not the declared 1048576: in practice
context overflow starts around 430k, so 40% of the declared window would fire
after it is already too late.
Token cost
The bus itself is cheap; the turns it triggers are not.
| Item | Cost |
|---|
| Tool schemas (5 tools) | ~1400 tokens per request, in every conversation, used or not |
| Message wrapper | ~120 tokens per delivered message (header, workspace, time, protocol lines) plus a few dozen for done |
| A woken turn | the target's whole context, re-sent as input |
The third row dominates. Waking a conversation that already holds 30k tokens of history
costs 30k input tokens for a 20-character message — and the message stays in that
history, so every later turn of that session is slightly more expensive as well.
Since 0.3.0 the plugin remembers items 1 and 3 for you (briefing, budget notice,
handover gate); nobody has to watch it.
Ways to spend less, best first:
- 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.
- Stop pasting logs into the conversation: write them to a file, grep the hits, paste only those lines.
- Make
done specific — it removes the "one more thing" follow-up that costs a whole context reload.
- Reserve
urgent for stuck/failed/decision — everything else rides the digest.
wake: false for anything that is not a "start working now" signal.
- Use
kind="通知" for state sync: it lands on disk and costs nothing.
- Never poll. A main conversation calling
bus_list every few minutes re-reads the most expensive context and learns nothing.
- Pointers, not payloads. Summary + file path in the body; the report stays in the file.
- Disable the plugin in profiles that never use it — that removes the ~1400-token schema.
New workspace setup (copy-paste prompt)
The bus does not create directories or write task files. In a new workspace:
- The main conversation first creates a coordination directory (the default convention is
handoff/; bus state goes in its .bus/ subdirectory) with its normal file tools. Do this first if you want state kept inside the project; otherwise state goes to the DSH user directory.
bus_register(role="main").
- Then write task/instruction files and dispatch with
bus_send.
The file names in the skeleton below are examples; rename them freely. A numeric prefix such as 40- is only an optional ordering note.
Set up handoff/ in this directory and start working through the session bus.
Step 1 — create the directory and skeleton files (follow this project's existing
handoff/ convention; if there is none, use the skeleton below):
handoff/README.md
handoff/instructions.md
handoff/pending.md
handoff/task-a.md, task-b.md
handoff/status-a.md, status-b.md (current status per worker)
handoff/report-a.md, report-b.md
Step 2 — register first, then verify:
bus_register(role="main", note="<date> main")
bus_list
Step 3 — to dispatch, write the task/instruction file first, then call:
bus_send(target="api", kind="指令", ref="D-001",
done="report-a.md is written and npm test passes",
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.")
Step 4 — sub-conversations only write their own status/report files;
handoff/.bus/ is the bus state (bindings.json / deliveries.jsonl) — do not edit it.
Create the files, register main, then show me the role tree with bus_list.
Sub-conversation prompt (send this at the start of every new conversation on a line):
You are a new conversation on the api line. First call bus_register(parent="api") to get your number,
then read handoff/api/STATE.md for progress. Only write your own status/report files under handoff/api/.
When done call bus_send(target="main", kind="回报", ref="R-<date>-01", message="Report written; conclusion: …; next: …").
Add urgent: true when stuck, failed, or you need a decision.
Multiple workspaces
- The bus is scoped by the caller session's cwd.
main in workspace A and main in workspace B are two independent buses; role names do not collide.
- Within one bus, the main and sub conversations must share the same cwd to address each other.
- State location:
- workspace has
handoff/ → <workspace>/handoff/.bus/
- otherwise →
<DSH_HOME>/handoff-bus/<sanitized-workspace>/
- The first
bus_register creates handoff/, migrating an existing central
state into it first, so the move never orphans bindings or counters.
- Cross-workspace addressing is not implemented. Open an issue if you need it.
Tools
| Tool | Purpose |
|---|
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 |
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 |
bus_whoami() | Current role + workspace |
bus_list() | Role tree (parent / depth / children / virtual) with signals (awaiting / msgCount / idleMin / routeTo / suggestHandoff) plus latest |
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 |
kind: 指令 (instruction), 回报 (report), 通知 (notice), 请求 (request). These four values are literal tokens — keep them in Chinese; do not translate them.
bus_whoami / bus_list / bus_send carry context (measured pct / tokens / level /
compactions; estimated: true when the host could not be probed);
bus_register / bus_whoami / bus_list also carry handoff (directory, entry files, minutes
since the last write); bus_send adds targetContext and warns when the target is already past
the watermark — archive it instead of piling on more work.
Wake policy when wake is omitted: 指令 / 通知 wake immediately; 回报 goes to the digest without waking; 请求 wakes immediately. An explicit wake always wins.
Configuration
Configuration is read in this order (later wins):
- built-in defaults (
DEFAULT_CONFIG)
- JSON file:
$DSH_HANDOFF_BUS_CONFIG, or <DSH_HOME>/handoff-bus/config.json
- the plugin row's
config in your profile patch (advanced)
Example config.json:
{
"handoffDir": "handoff",
"stateDirName": ".bus",
"maxMessageChars": 4000,
"maxSendsPerHour": 30,
"maxPairSendsPer10Min": 8,
"requireDone": true,
"routeMaxAttempts": 3,
"retiredRetentionDays": 7,
"digestReports": true,
"digestMinCount": 3,
"digestMaxWaitSec": 900,
"handoffAfterMessages": 40,
"handoffAfterIdleMin": 30,
"briefing": true,
"briefingFiles": ["README.md", "PROTOCOL.md"],
"briefingScan": [],
"briefingScanLimit": 4,
"briefingTailLines": 80,
"contextBudget": true,
"contextWindowTokens": 524288,
"contextWarnRatio": 0.4,
"contextStopRatio": 0.7,
"contextOverheadTokens": 40000,
"childWarnRatio": 0.5,
"childStopRatio": 0.7,
"mainWarnRatio": 0.5,
"mainDrainRatio": 0.85,
"compactionWarn": 1,
"compactionStop": 2,
"contextProbeSec": 60,
"drainSweepSec": 900,
"drainSweepIdleRounds": 3,
"drainSweepBackoffSec": 3600,
"requireHandoverOnLeave": true,
"handoverWindowMin": 30
}
centralStateRoot is optional (an absolute path); it defaults to <DSH_HOME>/handoff-bus. Restart the client after editing the file. See config.example.json.
| Key | Default | Meaning |
|---|
handoffDir | handoff | Coordination directory name; if present, state goes to <it>/.bus/ |
stateDirName | .bus | State subdirectory name |
centralStateRoot | <DSH_HOME>/handoff-bus | State root when the workspace has no coordination directory |
maxMessageChars | 4000 | Per-message limit |
maxSendsPerHour | 30 | Per-conversation hourly limit |
maxPairSendsPer10Min | 8 | Per role-pair limit, to stop ping-pong |
requireDone | true | kind="指令" must carry done |
routeMaxAttempts | 3 | Candidates probed when target is a line name |
retiredRetentionDays | 7 | How long retired roles are remembered |
digestReports | true | Batch plain reports (disable to wake on every report) |
digestMinCount | 3 | Reports per digest wake-up |
digestMaxWaitSec | 900 | Longest a report waits before the digest fires |
handoffAfterMessages | 40 | suggestHandoff threshold: messages exchanged |
handoffAfterIdleMin | 30 | suggestHandoff threshold: minutes idle |
briefing | true | Inject the start-up briefing before the first request of a conversation |
briefingFiles | ["README.md","PROTOCOL.md"] | Files to read in full, relative to the coordination directory |
briefingScan | [] | Globs read as structure + tail, e.g. |
deliveries.jsonl is append-only. Delete it manually if it grows; automatic rotation is planned for a later release.
Error codes
| Code | Meaning |
|---|
no_session | The tool call has no associated conversation |
no_workspace | The conversation has no cwd |
invalid_role / invalid_target / invalid_message / invalid_kind | Invalid argument |
caller_not_registered | This conversation has no role yet |
target_not_registered | The target role or line does not exist in this workspace |
line_busy | A conversation on this line still owes a 回报; wait, or preempt: true |
no_available_session | Every conversation on the line is retired (context full), or the line is empty |
draining | This main conversation is winding down: reports only, no new instructions |
self_retired | This conversation is retired (context full): write the ledger, ask the user to archive it |
role_not_found / role_virtual / not_owner | bus_unregister: no such role / it is a virtual line node / it belongs to another conversation |
self_target | The target is this conversation |
target_unresolvable | The target session cannot be opened (archived/deleted, or owned by subagent routing) |
rate_limited | Rate limit hit |
instruction_pending | bus_unregister only: an instruction to this role is still unanswered |
instruction_incomplete | kind="指令" is missing done |
handover_missing | The coordination directory was not written before leaving; write the state, or pass force: true |
flush_failed | Queued but persistence was not acknowledged |
delivery_failed | Delivery error |
aborted |
Security and trust model
- This is a host plugin with host-process privileges. Read the code before installing.
- Within one workspace, any conversation can register any role (including impersonating
main). Use it only in a trusted, single-user profile.
- Bus messages are injected as user-role messages and can wake the target. There are rate limits and an audit log, but no signing or encryption.
deliveries.jsonl records metadata only (who, when, to whom, message id, length); it does not store the body.
- No network requests, no telemetry.
Uninstall and cleanup
- Remove the plugin from the Plugins page and restart.
- State is not removed automatically:
<workspace>/handoff/.bus/ or <DSH_HOME>/handoff-bus/ — delete manually if needed.
Development
npm test
The test uses a fake DSH context and fake agents in a temporary directory; it needs no running Harness and never touches real state.
Design constraint: do not reintroduce @deepseek-ai/* imports (linked installs will fail with ERR_MODULE_NOT_FOUND). Tools are registered through the raw ctx.tools.register contract, and messages are constructed locally in the same shape as createUserMessage.
Repository: https://github.com/cwh-123456/dsh-handoff-bus
License
MIT — see LICENSE. This is an unofficial plugin, not affiliated with DeepSeek.