buslet
README.md
# buslet
中文 | [English](README.en.md)
[](https://github.com/mrchenguozheng/buslet/actions/workflows/ci.yml)
[](https://github.com/mrchenguozheng/buslet/releases)
[](LICENSE)
[](https://nodejs.org)
本机 agent 之间的消息总线——**local-first**:默认只在本机跑,不依赖云;以后要跨机器,再通过受控的远程入口接出去。让两个(或多个)独立的 agent 进程互相收发消息,而不是所有话都从编排者那里中转。
只有一个运行时依赖:`cron-parser`(见[定时任务](#定时任务))。其余全部是 Node 内置模块(`net`、`node:sqlite`、`node:crypto`),消息只落本机磁盘,不出网。需要 Node >= 22.13.0——`node:sqlite` 从这个版本起不再需要 `--experimental-sqlite`(22.5 引入,23.4 / 22.13 起转正;`node cli.mjs version` 会打印当前要求)。
这是一个独立项目目录:**以下命令都在本目录(`buslet/`)里执行**。名字的来历(bus + -let,以及内部线路 AKB-49)见 [docs/origin.md](docs/origin.md)。
```powershell
npm install
node cli.mjs start
```
状态(消息库、密钥、token)默认在本目录下的 `.buslet/`,已在 `.gitignore` 里。换目录运行时要显式 `BUSLET_DIR`。
## 为什么不直接用现成的
现成的"agent 通信"方案基本分两类:
- **云端的 agent 协议**(A2A 之类):要注册、要暴露端点,消息出门。
- **PyPI 上的 `agent-bus-mcp` / `crosstalk-mcp` / `agent-group-mcp`**:能用,但装进来的是第三方代码,且它们**必然能看到你所有 agent 之间的明文消息**。
这个实现的取舍是:协议用现成的 MCP(任何支持 MCP 的客户端都能接),传输用最小实现(Node 内置 `net` + `127.0.0.1`),存储用 Node 内置 SQLite,代码几百行,可以自己读完再决定信不信。对这三个包的具体拆解见 [docs/prior-art.md](docs/prior-art.md)。
## 语义:追加日志 + 游标
消息是**只追加的日志**,不是会被取走的队列:
- 每条消息有全局递增的 `seq`;
- 每个 agent 有一个游标(`cursors.last_seq`),`inbox` 返回 `seq > 游标` 的消息并推进游标;
- 所以崩溃、重启、重复读都不会丢消息;`--peek` 读而不推进,`--since N` 从任意位置重放;
- `listen` 有**自己的一套游标**(按 `--consumer` 区分),不会吃掉 `inbox` 还没读的邮件;要让它也推进收件箱,加 `--consume`;
- `ask` 走独立的 `await` 通道按 `reply_to` 等回复,**不动收件箱游标**;拿到回复后只 ack 这一条(`--keep` 可保留),所以既不会二次处理,也不会吞掉别的邮件;超时时回复仍留在 inbox;
- 需要更细的控制时用 `ack <seq>` 按条目标记已处理;
- 广播只发给"发送时已经在总线上"的 agent,后来加入的不会被旧广播刷屏;
- 保留期默认 7 天 / 最多 2 万条,可用 `BUSLET_RETENTION_DAYS`、`BUSLET_MAX_ROWS` 调整;死信默认同保留期一起过期(`BUSLET_DEAD_TTL_DAYS` 可单独调),悬挂死信(正文已被清理)随清理周期一并回收;
代价:消息会落盘(`bus.db`)。如果某次对话不该留痕,结束后删掉 `.buslet` 目录即可。
## 它是被动的:不启动 agent
buslet 是消息总线,不是进程管理器。它**不会启动、拉起、重启或叫醒一个没有在跑的 agent**。
- broker 能“唤醒”的只有已经存在的等待:`inbox --wait`、`listen`、`bus_wait`、`wait --group` 这类调用会被新消息或到期任务解除阻塞;这只是让调用方去读库,不是启动进程。
- agent 进程没在跑时,消息留在 SQLite;下次它启动并读 `inbox` 才看到。
- 没有 headless 命令的 agent(例如桌面版豆包)只能由人手动提示它去读总线;buslet 不会替它操作 UI,也不内置 CDP/UI 自动化。
- 轮询型客户端只按自己的调度读消息。豆包桌面版的定时任务最短 1 小时一次,所以它这一侧最坏延迟约 1 小时;buslet 不控制这个周期。
- 任何能在非交互环境执行命令的 agent,都可以用自己的调度器轮询 `inbox`;延迟等于它的调度周期。接入方式见 [docs/onboarding.md](docs/onboarding.md#轮询型-agent豆包--其他定时任务)。
- `supervise` 只保证 broker 自己活着,不负责 agent 进程。
需要“消息到达后启动一个命令”的 runner 是另一个功能;目前没有实现,也不依赖任何不存在的 CLI。
## 安全模型
| 项目 | 做法 |
| --- | --- |
| 网络暴露 | 只 bind `127.0.0.1`,端口随机。局域网/公网连不上 |
| 认证 | 每次启动随机生成 24 字节 token,存在 `bus.json`(权限 600),每个请求都要带 |
| 外发流量 | 没有。broker 不发起任何出站连接,不上报、不检查更新 |
| 落盘 | 只有本机 SQLite(`bus.db`),不写系统日志、不写用户目录;默认保留 7 天 |
| 生命周期 | broker 停了历史仍在;删掉状态目录即彻底清除 |
**它防不住什么**:同机器上能读你工作目录的其他程序/用户(token 和消息库都在 `.buslet/` 里);本机管理员。回环流量不加密——需要防本机抓包就是另一个量级的设计(mTLS),这里没做。
## 多 agent 并行写文件
总线不能让别的进程写不了文件,但能把"谁在改哪个文件"变成可见、可协调的状态:
```powershell
node cli.mjs claim README.md docs/limits.md --as dsh --ttl 600 --task t7
node cli.mjs claims
node cli.mjs release README.md docs/limits.md --as dsh
```
- `claim` 是**建议性租约**:冲突时后到者拿到 `conflict` 和持有者(`holders`),不会静默覆盖;忘记释放会在 TTL 后过期(默认 900s)。
- 它只约束愿意先 claim 的 agent;不 claim 的进程照样能写。真正的强制要么靠调度(同一文件同一时间只派给一个 agent),要么靠隔离(每个 agent 一个 `git worktree`/分支,最后合并)。
- 同一 checkout 里用仓库相对路径;跨 worktree/不同机器用绝对路径——claim 按字符串比较,Windows 下不分大小写。
- claim 存在 broker 内存里:broker 重启即清空,适合"短时间改一批文件",不是分布式锁。
配套的调度约定:任务消息里写死文件范围;派发者不再碰已派出的文件;写前 claim、提交后 release。
## 评审也走总线
代码修改意见可以直接发到对方的 inbox,不需要另开一个聊天窗口:
```powershell
# 评审方:对着任务(seq 130)发意见
node cli.mjs send --to workbuddy --kind review --thread t7-mcp --reply-to 130 `
--meta '{"files":["mcp-server.mjs"],"severity":"blocking"}' `
--text "listResources 里 whoami 失败时不应该缓存 false,重连后会一直少 groups。"
# 双方都能读整条 thread(绑定 token 只看到自己发/收的)
node cli.mjs thread t7-mcp
# 作者回复
node cli.mjs send --to dsh --kind review-reply --thread t7-mcp --reply-to 138 --text "改了,见 3f2a1c。"
```
- `kind` 只是标签(`review` / `review-reply` / `question` 都行),真正把对话串起来的是 `thread` 和 `reply_to`;
- `meta` 适合放结构化字段(文件、行号、severity),正文放人读的意见;
- 评审是异步的:消息进 inbox,对方下次 `inbox` / `thread` 才看到——它不会打断正在跑的 agent;
- 要"阻塞等一个答复"才用 `ask`,且双方都得在线。
完整格式、可见性规则和 T7 那次"任务前提被代码推翻"的例子见 [docs/review.md](docs/review.md)。
## 快速开始
```powershell
# 起 broker(后台常驻,自动脱离终端;CLI 也会自动拉起)
node cli.mjs start
# 发 / 收
node cli.mjs send --to bob --from alice --text "任务"
node cli.mjs inbox --as bob --wait 60
node cli.mjs listen --as bob # 实时流
node cli.mjs log --limit 20 # 看总线上最近的流量
```
请求/响应:
```powershell
# alice 发问并阻塞等回复(拿到 question 的 seq,回复用 --reply-to 关联)
node cli.mjs ask --as alice --to bob --text "6*7 等于几?" --timeout 60
# bob 侧:收件箱里看到 #2 {question},回复时带上 --reply-to 2
node cli.mjs send --from bob --to alice --text "42" --kind answer --reply-to 2
```
## 命令
| 命令 | 说明 |
| --- | --- |
| `start` / `stop` / `status` | 管理 broker |
| `doctor [--json]` | 体检:库大小、保留策略、积压、死信、过期租约;有警告时退出码 1 |
| `mcp-config [--name NAME] [--with-token]` | 打印可直接粘贴的 MCP 配置(路径绝对化,可带上当前 token) |
| `connect --client ID --name N [--issue-token] [--write]` | 直接把 MCP 条目写进客户端配置(workbuddy / cursor / claude / vscode / codex;默认只打印,`--write` 才落盘并备份) |
| `backup [--out DIR]` | 在线备份:SQLite 快照 + 密钥 + sha256 清单(broker 不用停) |
| `restore <BUNDLE> [--force]` | 校验清单、停 broker、覆盖状态、重启;原状态留在 `restore-undo-*` |
| `ui [--port N]` | 本机只读面板:peers、组水位、死信、调度、最近流量 |
| `auth add\|list\|revoke\|whoami` | 每个 agent 的 token:签发、查看、吊销、看当前绑定 |
| `secure [--migrate]` | 查看静态加密状态;`--migrate` 加密历史明文行 |
| `peers` | 已知 agent 与最后活动时间 |
| `log [--limit N]` | 最近 N 条流量(审计视角,不分收件人) |
| `send --to NAME --text T [--kind K] [--reply-to SEQ] [--thread ID] [--priority N] [--meta JSON] [--dedupe-key K] [--notify]` | 发消息;`--to '*'` 广播;`--priority` 影响组队列顺序;`--dedupe-key` 让重发幂等;收件人从未出现过时 stderr 给 warning;`--kind task` 在 Windows 上默认弹桌面通知提醒去催,`--notify` 对普通消息也弹,`BUSLET_NOTIFY=0` 关闭 |
| `inbox --as ME [--wait S] [--max N] [--peek] [--since SEQ]` | 取消息;`--peek` 不推进游标,`--since` 重放 |
| `listen --as ME [--consumer NAME] [--since SEQ] [--consume]` | 订阅推送(NDJSON);独立游标,`--consume` 才同步推进收件箱游标 |
| `ask --to NAME --text T [--as ME] [--timeout S] [--keep]` | 发问并等 `reply_to` 关联的回复;默认 ack 掉这条回复 |
| `ack SEQ --as ME` | 把某条消息标记为已处理(不再出现在 inbox) |
| `thread <id>` | 列出共享同一 thread id 的所有消息 |
| `trace <seq>` | 一条消息的完整投递轨迹:队列、尝试次数、租约、ack、死信 |
| `tasks [--as ME] [--limit N]` | 从消息日志投影任务板(读 `meta.task_id/state`,无额外存储) |
| `work --as ME --group G [--max N] [--wait S] [--lease S] [--max-inflight N]` | 从消费者组领活(租约制、在途上限) |
| `ack --group G SEQ...` | 确认组里的活已处理,租约结束、不再重投 |
| `nack --group G SEQ... [--retry-in S]` | 没做完:释放租约,按退避/指定秒数排下一次 |
| `groups [--json]` | 每个组的积压:total / acked / leased / dead / sched / pending / 尝试上限 |
| `wait --group G [--timeout S]` | 阻塞到该组落定(无 pending/leased/scheduled);超时退出码 4 |
| `reset --group G --yes` / `reset --all --yes` | 清空一个队列 / 清空所有消息和队列状态(admin 才允许) |
| `dead --group G [--json]` | 列出死信(重试耗尽的活) |
| `requeue --group G SEQ...` | 把死信放回队列,尝试次数清零 |
| `schedule add\|list\|remove` | 固定间隔任务:`--every 5m --text "..."` |
`--dir PATH` 对所有命令都有效,用来指定状态目录。**`--json` 不是所有命令都有**:`listen` 本身就是 NDJSON 流(一行一条消息);`supervise` / `ui` / `connect` 是把控制权交给前台子进程,不存在"换输出格式";`mcp-config` 打印的本来就是 JSON 配置;`stop` / `uninstall` 只有几行人读状态。这几个传了 `--json` 也不会变成机器可读。`start` 特殊:默认打一句人读的,显式传 `--json` 才输出状态对象。其余命令(`status`、`version`、`doctor`、`peers`、`log`、`thread`、`trace`、`tasks`、`groups`、`dead`、`inbox`、`send`、`work`、`wait`、`ack`、`ask`、`reset`、`secure`、`backup`、`restore`、`install`、`service`,以及 `schedule list`、`auth list` / `auth whoami`)都支持 `--json`。
## 消费者组(工作队列)
发给 `group:名字` 的消息不进任何人的个人收件箱,而是进一个队列,由 `work` 领取:
```powershell
node cli.mjs send --from alice --to group:builders --text "job 1"
# 三个 worker 同时跑,每条活只会交给其中一个
node cli.mjs work --as w1 --group builders --max 2
# #18 ... job 1
# #19 ... job 2
# leased 2 for 60s — ack with: buslet ack --group builders 18 19
node cli.mjs ack --group builders 18 19
# 协调者不用轮询:阻塞到队列"落定"(没有 pending/leased/scheduled)
node cli.mjs wait --group builders --timeout 300
# settled: total 2, acked 2, pending 0, leased 0, scheduled 0, dead 0
```
语义:
- **每条消息只交给一个 worker**:领取时写入租约,其他人看不到;
- **at-least-once**:`--lease` 秒内没 `ack`,租约过期后重新可见;
- **失败先退避**:`nack`(或租约过期)不会立刻重投,而是按 `base * 2^(n-1)` + 抖动排到下个时间点,默认 base 5s、上限 300s(`BUSLET_BACKOFF_BASE` / `BUSLET_BACKOFF_CAP`)。到点由 broker 唤醒正在等待的连接(不是启动 worker 进程);
- **进度不靠游标**:组内已处理的消息记在 `group_acks`,所以重启、换 worker 都不会重放;
- **不让人吃独食**:每个 worker 在途(已领未 ack)有上限,组级 `--max-inflight`(默认 10,0 = 不限)。领满了就返回 `throttled`,把剩下的留给别人;
- **优先级**:`send --priority N`(默认 0),`work` 按 `priority DESC, seq ASC` 取。适合"紧急单插队到批量任务前面";可选**老化(aging)**:设 `BUSLET_PRIORITY_AGING_SECONDS=N` 后,每多等 N 秒有效优先级 +1,被高优先级洪流饿住的低优先级任务最终能排上(默认 0 = 关闭,`doctor` 可见);
- **失败有刹车**:同一条消息投递满 `--max-attempts` 次(默认 5)就进死信,不再占用 worker。这个上限是**组级配置**:`work` 里显式传了才改,之后全组按最后一次传的值算;
- `work` 返回里带 `attempts / max_attempts / held / throttled` 和 `stats`,可以判断重试剩几次、自己占了几个槽、队列是否见底;
- `groups` 一次看所有组:`total / acked / leased / dead / scheduled / pending / 尝试上限`(`--json` 里还有 `max_inflight`)。
```powershell
# 做不完就先还回去,3 秒后再来(不写 --retry-in 就用指数退避)
node cli.mjs nack --group builders 28 --retry-in 3
# 紧急任务插队;每个 worker 最多同时占 2 个
node cli.mjs send --to group:builders --text "hotfix" --priority 10
node cli.mjs work --as w1 --group builders --max-inflight 2
```
死信要人管,不会自动复活;但会过期:默认跟保留期一样(7 天)后由清理周期回收,正文已被保留期删掉的悬挂死信也会一并清掉:
```powershell
node cli.mjs dead --group builders # 列出死信、原因、原文
node cli.mjs requeue --group builders 28 # 修好之后再放回队列(尝试次数清零)
```
MCP 里对应 `bus_work` / `bus_ack` / `bus_nack` / `bus_groups` / `bus_dead` / `bus_requeue`。
## 定时任务
两种写法:固定间隔,或 cron + 时区。
```powershell
node cli.mjs schedule add --group builders --every 5m --text "巡检队列水位"
node cli.mjs schedule add --group standup --cron "0 9 * * 1-5" --tz Asia/Shanghai --text "工作日早会"
node cli.mjs schedule list
node cli.mjs schedule remove tYnKHlNPjO7O
```
- 到点就往组里投一条 `job` 消息(`from` 是 `scheduler`),走的是普通队列语义:优先级、重试、死信都照常;
- **投递和推进 `next_at` 在同一个事务里**,所以崩溃不会重复触发;
- 停机期间错过的窗口**不补发**——启动时最多补一次"已到期"的触发,不会连发一串;
- 消息 `meta` 带 `{schedule_id, fire_at}`,消费者要去重可以用它。
`--every` 接受 `30s` / `5m` / `1h` / `1d` / `500ms`。cron 用标准五段式,`--tz` 是 IANA 名称(默认 broker 本地时区):夏令时跳变里不存在的时刻会跳过,重复的时刻只触发一次。细节见 [docs/scheduler-options.md](docs/scheduler-options.md)。
现成的派发器示例(生产者 + 两个 worker + 失败重试)在 [examples/](examples/README.md):
```powershell
node examples\demo.mjs
```
## 接入 MCP 客户端
`mcp-server.mjs` 是 stdio MCP server,暴露 17 个工具:`bus_peers`、`bus_send`、`bus_inbox`、`bus_wait`、`bus_ask`、`bus_log`、`bus_thread`、`bus_trace`、`bus_work`、`bus_ack`、`bus_nack`、`bus_groups`、`bus_wait_group`、`bus_schedule`、`bus_dead`、`bus_requeue`、`bus_backup`(仅 admin token——备份里含解密全库的 key)。
**"流式"在 MCP 里有两个层次**:
1. `bus_wait` —— 长轮询,阻塞到有新消息或超时(最长 1800s),每次返回一条。**所有 MCP 客户端都能用**,这是可移植的那条路。
2. resources + 订阅 —— 支持 resources 的客户端可以读 `buslet://inbox/<name>` 和 `buslet://groups`;支持订阅的客户端在 `resources/subscribe` 后会收到 `notifications/resources/updated`,再自己 `resources/read`。
注意 MCP 没有"服务端把内容推进模型上下文"的标准能力,所以真正的推送只对支持 resources 的客户端成立;其他客户端等价于轮询。细节和限制见 [docs/limits.md](docs/limits.md#5-mcp)。
不要手写路径,让 CLI 生成(它会把 `BUSLET_DIR` 钉成绝对路径,避免客户端用不同的 cwd 启动 server 时连到另一个状态目录):
```powershell
node cli.mjs mcp-config --name claude-code
```
```json
{
"mcpServers": {
"buslet": {
"command": "node",
"args": ["C:\\...\\buslet\\mcp-server.mjs"],
"env": {
"BUSLET_NAME": "claude-code",
"BUSLET_DIR": "C:\\...\\buslet\\.buslet"
}
}
}
}
```
身份从 `BUSLET_NAME` 来,工具参数里没有 `from`——模型没法以别人的名义发言。每个参与的 agent 用不同的名字。
没有 MCP 的 agent(只会跑 shell 的),直接用 CLI。
## 身份绑定(可选)
默认只有一个 **admin token**(在 `bus.json` 里):任何拿到它的进程都能用任意名字发言、读任何人的收件箱。要收紧,就给 agent 发各自的 token:
```powershell
node cli.mjs auth add claude-code # 只打印一次,保存在环境变量里
# 然后让这个 agent 带 token 启动:
$env:BUSLET_TOKEN = "abk_..."
```
可以再收窄到"只读"或"只发"的 bot,并加有效期:
```powershell
node cli.mjs auth add reviewer --scope read --ttl 7d
node cli.mjs auth add build-bot --scope send,work --ttl 30d
```
scope 有四个:`send` / `read` / `work` / `schedule`(`ack` 个人消息算 `read`,组 ack 算 `work`)。每个 scope 可以再加窄到单个目标:
```powershell
node cli.mjs auth add reviewer --scope read --ttl 7d
node cli.mjs auth add bot --scope send:alice,send:group:builders,work:builders
node cli.mjs auth add cron-bot --scope schedule:builders
```
`send:alice` 只能发给 alice,`send:group:builders` 只能发给该组,`work:builders` 只能领这一个组,`schedule:builders` 只能给该组建调度。不带 scope 的 token 保持原来的全量 agent 权限;到期后 token 直接失效,`auth list` 里状态是 `expired`。这是允许列表,没有"排除"或按消息内容过滤。
这个 token 会被 broker **绑定到名字**:
- 不能以别的名字 `send` / `hello` / `read` / `work` / `ack`(返回 `forbidden`);
- 不能读别人的收件箱;
- 不能读总线级视图:`log`、`dead`、`stats`、`doctor` 都是操作员动作;`thread` / `trace` 只能看自己发、收或经手的消息(别人的 `forbidden`);
- `schedule list/remove` 只能看到、只能删掉自己创建的定时任务(`created_by`);`peers` / `groups` / `wait_group` 这类只有名字和计数的视图仍然开放;
- 不能发 token、不能 `requeue`、不能关 broker。
broker 只存 `sha256(token)`,`auth list` 看得到谁有 token、最后用是什么时候,`auth revoke NAME` 立刻失效。`auth whoami` 显示当前 token 绑到谁。换 token 用 `auth rotate NAME`:原地替换,旧 token 立刻失效,scope 和过期时间保持不变。
MCP 侧把 token 写进 env 块即可:`mcp-config --name X --with-token` 会从当前环境读 `BUSLET_TOKEN` 一起输出。
一条命令写进客户端配置(默认只打印,`--write` 才落盘,先备份):
```powershell
node cli.mjs connect --client workbuddy --name workbuddy --issue-token --write
node cli.mjs connect --client cursor --name claude-code --token $env:BUSLET_TOKEN --write
node cli.mjs connect --client vscode --name vscode --token $env:BUSLET_TOKEN --write
node cli.mjs connect --client codex --name codex --token $env:BUSLET_TOKEN --write
```
| client | 文件 | 格式 |
| --- | --- | --- |
| `workbuddy` | `~/.workbuddy/mcp.json` | `mcpServers` |
| `cursor` | `~/.cursor/mcp.json` | `mcpServers` |
| `claude` | Claude Desktop 的 `claude_desktop_config.json` | `mcpServers` |
| `vscode` | `./.vscode/mcp.json`(在项目根目录运行) | `servers` + `type: stdio` |
| `codex` | `~/.codex/config.toml`(或 `$CODEX_HOME`) | `[mcp_servers.buslet]` |
- `vscode` 是工作区级配置,默认写当前目录的 `.vscode/mcp.json`;用 `--config` 指到别的项目。
- `codex` 会替换已有的 `[mcp_servers.buslet]` 表,保留其他表和注释;写前有 `.bak-*` 备份。
- 其他客户端用 `--config PATH --format json|vscode|toml`,或直接打印出来手贴。
**看总线**:`node cli.mjs ui` 起一个只 bind `127.0.0.1` 的只读面板(peers / 组水位 / 死信 / 调度 / 最近 40 条),两秒刷新;最近消息可以按组、按 agent、按关键词过滤(点 peers/组那一行也能直接筛),点一条消息看它的 trace。`/api/state` 要页面内嵌的随机 key,别的网页读不到。
**接一个新 agent**(WorkBuddy、Claude Code、Codex…)的完整步骤见 [docs/onboarding.md](docs/onboarding.md)。
**它防不住什么**:同机同用户的进程仍然能读 `bus.db`、`.buslet/key`(有钥匙就能解密)和 `bus.json`(admin token)。这是"防误用/防冒充"而不是"防恶意本机进程",完整威胁模型见 [docs/limits.md](docs/limits.md#1-传输与信任)。
## 静态加密
消息正文和 `meta` 用 AES-256-GCM 加密后落盘;路由字段(seq / from / to / kind / ts / thread)保持明文,索引和查询不受影响。
```powershell
node cli.mjs secure # 看状态
node cli.mjs secure --migrate # 把旧库里还没加密的行一次加密
```
- 密钥在首次启动时自动生成到 `.buslet/key`(0600)。也可以用 `BUSLET_KEY`(base64 的 32 字节)或 `BUSLET_KEY_FILE` 指定,`BUSLET_ENCRYPT=0` 关闭加密。
- 旧库里的明文行仍然可读;迁移是显式的,不会在启动时偷偷改写你的数据。
- 换错密钥读出来的是 `[encrypted: wrong key]`,不会崩溃。
- 代价:直接对 `bus.db` 做 `strings`/grep 已经看不到内容了;`log` / `thread` 仍然可读,因为 broker 解密后输出。
**它防不住什么**:密钥和库在同一个目录,同用户进程能同时拿到两者。它防的是"只拷走了 `bus.db`"——备份、同步盘、误发文件、把库贴给别人。要防本机攻击者,得把密钥放到别处(`BUSLET_KEY` 从环境注入、库放到另一个目录)。
## 备份与恢复
```powershell
node cli.mjs backup --out D:\backups\buslet-2026-10-29
```
- broker **不用停**:用 SQLite 的 `VACUUM INTO` 取一致性快照,源库只读不写;
- 产物是一个目录:`bus.db`、`key`(如果启用了加密)、`manifest.json`(版本、消息数、每个文件的 sha256);
- 默认输出到当前目录的 `backups/buslet-<时间戳>/`,`--json` 输出清单。
恢复:
```powershell
node cli.mjs restore D:\backups\buslet-2026-10-29 --force
```
- 先按 `manifest.json` 校验每个文件的 sha256,坏包直接拒绝;
- broker 在跑时默认拒绝,`--force` 才会停它;
- 覆盖前把当前 `bus.db`/`key` 存到 `<state>/restore-undo-<时间戳>/`,并清掉旧的 `-wal`/`-shm`;
- 覆盖后自动重启 broker、读一次统计;启动失败会把 undo 拷回去并重启旧状态;
- 手工恢复仍然是:`stop` → 覆盖 `bus.db` + `key` → `start`。
只备份 `bus.db` 不备份 `key`,等于拿到一堆密文;反过来只拷 `key` 也没用。
## 保活与自启
broker 是 CLI 按需拉起的 detached 进程;机器重启或进程崩了,它不会自己回来。要让它一直在,用监督进程:
```powershell
node cli.mjs supervise # 前台跑:broker 死了自动重启(指数退避,最多 30s)
node cli.mjs install # 在启动目录写一个条目,登录时拉起 supervisor
node cli.mjs service # 看 autostart / supervisor / broker 三者的状态
node cli.mjs uninstall # 移除启动条目
```
- `supervise` 会**收养**已经在跑的 broker,不会起两个;它每 2 秒检查一次,发现挂了就重启。
- `install` 只往用户启动目录(Windows 是 `%APPDATA%\...\Startup`,Linux 是 `~/.config/autostart`)写一个 `.cmd` / `.desktop`,内容就是 `node src/supervise.mjs --dir <绝对路径>`——没有注册表、没有计划任务、没有 COM,卸载就是删文件。
- 写入失败(比如受限环境)会打印**可以手工粘贴的完整命令**,退出码 3。
- `stop` 会先把 supervisor 停掉再关 broker,否则 supervisor 会立刻把 broker 拉回来。
## 单文件分发
```powershell
npm install
npm run build:sea
# dist\buslet.exe (~96 MB,内含 Node 运行时)
```
一个 exe 同时是 CLI、broker 和 MCP server(`--broker` / `--mcp` 是内部参数)。收发、组队列、调度、auth、backup/restore、trace 都在;`ui` / `connect` / 自启守护只在源码安装里提供,单文件版会明确报错。`mcp-config` 会直接输出 `command=buslet.exe, args=["--mcp"]` 的配置。
细节(平台签名、状态目录、升级、验证命令)见 [docs/distribution.md](docs/distribution.md)。
## 排障
```powershell
node cli.mjs doctor
```
示意输出(端口、pid、组数、消息数都会随库和时刻变化,别把这里的 "13 个组" 当成固定值):
```
broker v0.19.0 port 49238 pid 19976 up 0s
database 152 KB (wal 4 KB) messages 55 max seq 55
retention 7d / 20000 rows dead letters 7d last prune 2026-10-29T14:00:18Z (removed 0, dead 0)
peers 16 known, 0 online
groups 13 — pending 12, leased 0, scheduled 0, dead 1
leases 0 active, 10 expired
aging off
auth 1 active agent token(s), 3 ever issued
encrypt on — 55/55 message(s) encrypted
schedules 0
! 1 dead letter(s) in group "demo-dead" — run: buslet dead --group demo-dead
! group "wake" has 1 pending and no worker holding anything
```
有警告时退出码是 1,可以直接塞进脚本。`--json` 给机器读。检查项:broker 在不在、库多大、保留策略上次清了多少、组积压/死信/在途、过期租约、token、加密、调度。
再往下就是 `groups`(哪组积压)、`dead --group`(哪条卡住)、`log`(最近流量)。
## 协议
TCP 回环 + NDJSON,一行一个 JSON 对象:
```jsonc
// 请求:每个请求都要带 id 和 token(token 是 bus.json 里的 admin token,或 auth add 发的 abk_...)
{"id":1,"token":"...","cmd":"send","from":"alice","to":"bob","text":"hi","kind":"message","reply_to":null}
{"id":2,"token":"...","cmd":"read","as":"bob","wait":30,"max":50,"peek":false,"since":null}
{"id":3,"token":"...","cmd":"await","as":"alice","reply_to":2,"wait":120,"consume":true} // 等 #2 的回复
{"id":4,"token":"...","cmd":"ack","as":"bob","seq":7}
{"id":5,"token":"...","cmd":"work","as":"w1","group":"builders","max":2,"lease":60,"wait":0}
{"id":6,"token":"...","cmd":"ack","group":"builders","seqs":[18,19]}
{"id":7,"token":"...","cmd":"subscribe","as":"bob","consumer":"listen","consume":false} // 先回一行确认,之后同连接持续推流
{"id":8,"token":"...","cmd":"trace","seq":42}
{"id":9,"token":"...","cmd":"groups"} // peers / stats / log / thread / shutdown 同样格式
// 响应
{"id":2,"ok":true,"messages":[{"seq":3,"id":"...","from":"alice","to":"bob","kind":"message","text":"hi","ts":"...","reply_to":null,"thread":null,"meta":null}],"cursor":3,"has_more":false}
```
投递语义:个人收件箱是**追加日志 + 每 agent 游标**(至少一次、可重放),`subscribe` 用独立的 `sub_cursors`,与 `read` 互不影响;`acks(name, seq)` 记录按条目的"已处理"标记。`group:` 地址走另一套:租约 + `group_acks`,每条消息只交给一个 worker,租约过期重投。
完整命令、错误码和兼容约定见 [docs/protocol.md](docs/protocol.md)。
## 文件
```
buslet/
README.md # 中文说明
README.en.md # English README
AGENTS.md # agent 操作约定(启动时先读)
package.json # 唯一的运行时依赖:cron-parser
.github/workflows/ci.yml # CI:ubuntu/windows/macos × Node 22/24
.gitignore # node_modules / .buslet / .tmp
cli.mjs # 命令行入口
mcp-server.mjs # MCP stdio 入口
entry.mjs # 单文件入口:cli / --broker / --mcp
src/
broker.mjs # 常驻进程:回环监听、token、订阅推送、等待、到期唤醒等待连接
store.mjs # SQLite:schema、追加写、游标、租约、死信、退避、加密
client.mjs # 发现 + 自动拉起 broker + RPC
cron-adapter.mjs # cron 解析/下次触发(薄封装 cron-parser)
service.mjs # 自启条目(Startup 目录)+ 状态
supervise.mjs # 守护进程:broker 挂了自动重启
config.mjs # 生成 MCP 配置
connect.mjs # 一条命令写进客户端配置(workbuddy / cursor / claude / vscode / codex)
backup.mjs # VACUUM INTO 在线快照 + key + 清单
restore.mjs # 校验、停 broker、覆盖、重启(带 undo 回滚)
ui.mjs # 本机只读面板(HTTP + 内联页面)
version.mjs # package.json 注入(SEA 构建时内联)
notify.mjs # Windows 桌面通知(任务派发提醒)
examples/ # 派发器模板(worker + handler + demo)
test/bus.test.mjs
tools/ # smoke(工作流演练)、verify(一条命令跑全部)
docs/onboarding.md # 接入一个新 agent(WorkBuddy 等)
docs/prior-art.md # 同类实现怎么做的
docs/prior-art/ # 两家的逐行分析
docs/scheduler-options.md # 三档调度的设计与边界
docs/rehearsal.md # 三 agent 真实演练记录
docs/limits.md # 已知边界汇总
docs/protocol.md # RPC 协议规格(第三方客户端照着实现)
docs/review.md # 评审走总线的格式与可见性规则
docs/origin.md # 名字来历与 AKB-49 内部代号
docs/remote.md # 远程/多机设计 + 阶段 1 传输层(实验)
docs/collaboration.md # agent 协作约定(角色/任务状态/消息格式)
docs/release-notes/ # 每个 tag 的 release 说明(中英)
docs/distribution.md # 单文件可执行(SEA)构建与平台说明
docs/testing.md # 跑测试的三条约定与宿主环境的坑
tools/build-sea.mjs # npm run build:sea
LICENSE # MIT
```
测试:
```powershell
npm test # 在 buslet 目录下:单元/集成测试 + 工作流 smoke,一条命令
```
只需要其中一层时用 `npm run test:unit` / `npm run smoke`。三层都不预起 broker、不联网。测试脚本的三条硬约定(每次独立目录、删目录前轮询 pid、收尾失败不改退出码)以及宿主环境把 `fs.rmSync` 换成 trash 时的表现,见 [docs/testing.md](docs/testing.md)。
```
=== summary ===
PASS unit + integration (18.2s)
PASS workflow smoke (priority, 2 workers, retry, wait, outputs) (1.5s)
ALL PASSED
```
也可以单独跑:`npm run test:unit`、`npm run smoke`。失败时停在第一个失败的步骤,退出码就是那一步的。
(不要用 `node --test`,它需要 spawn 子进程,在受限沙箱里会 EPERM。)
## 已知限制
完整的边界清单(传输、投递语义、组队列、调度、MCP、存储、运维)在 [docs/limits.md](docs/limits.md)。最常踩的几条:
- 状态目录默认 `<cwd>\.buslet`。跨目录/跨工具时用 `BUSLET_DIR` 指到同一处;**别指系统临时目录**——每个 agent 会话的 `TMP` 往往是私有的,会各起一个 broker 互相看不见。
- 没有"处理完成"确认:`read` 推进游标后进程崩溃,那批消息就跳过了(要严格至少一次,用 `--peek` 读完再手动 `--since` 推进)。
- 回环不加密、token 同用户可读:同机同用户的进程可以冒充任何身份。
- 组消息至少一次投递,长任务要自己幂等;死信要人工 `requeue`。
- 优先级老化默认关闭;开启后不设上限,极老的低优先级任务最终会超过新的高优先级任务(这是反饥饿的本意)。
- MCP 没有服务端推送进模型上下文:只能用 `bus_wait` 长轮询,resources 订阅取决于客户端。
## 许可证
MIT,见 [LICENSE](LICENSE)。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues