Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务

This commit is contained in:
WpyQwq
2026-09-19 11:52:42 +08:00
commit 103f0b32f5
28 changed files with 5834 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# 总线协议(protocol)
## 目录布局
总线根目录默认 `~/.ai-groups`(`AIGROUP_HOME` 可覆盖):
```
~/.ai-groups/
├── current-room # `aig use <room>` 写在这里
└── rooms/
└── <room>/
├── messages.jsonl # 唯一真相:append-only 消息日志
├── state.json # { nextSeq, createdAt, updatedAt }
├── members.json # 成员画像:通道 / 模型 / 备注
├── cursors/<member>.json # 每个成员读到哪了(#seq)
├── presence/<member>.json # 心跳:lastSeen / pid / 宿主
├── files/ # send --file 的附件副本
└── .lock/ # 写锁(mkdir 原子锁)
```
## 消息结构
```json
{
"id": "m0007-mf3k2p-a91x",
"seq": 7,
"ts": 1789743812212,
"time": "2026-09-18T15:03:32.212Z",
"room": "main",
"from": "dsh",
"to": ["opencode"],
"kind": "chat | ask | reply | dispatch-error",
"via": "cli | dispatch",
"reply_to": "m0006-...",
"tags": [],
"files": [],
"text": "正文",
"meta": {}
}
```
- `seq` 在房间锁内分配,单调递增,是「读到哪了」的唯一依据。
- `to` 为空数组 = 广播给所有成员;`to` 里有 `*` / `all` 等价于广播。
- `kind`:
- `chat` 普通发言;`ask` 由 `aig ask` 发出的提问;
- `reply` 被唤醒成员的正式回答(`via: dispatch`);
- `dispatch-error` 唤醒失败时由总线代发的错误消息——**失败必须可见**。
## 游标语义
- 每个成员一份游标 `cursors/<member>.json`,只被该成员自己的 `read` / `wait` 推进。
- 未读 = `seq > 游标` 且 `visibleTo(消息, 成员)`。
- `visibleTo`:自己发的不算;`to` 为空算广播;否则必须在 `to` 里。
- `read --peek` 不推进游标;`read --all` 打印整段历史并按最后一条推进。
- 被 `aig wake` 处理过的未读,会把该成员的游标推到那批未读的最大 `seq`(避免同一批消息被反复唤醒)。
## 并发与一致性
- 写入走 `rooms/<room>/.lock`(`mkdir` 是原子操作),锁内完成「取 seq + append + 写 state」。
- 锁有 20s 陈旧保护(进程被杀不会永久锁死房间),获取超时 8s 后报错退出。
- 追加用 `fs.appendFileSync`,读侧跳过解析失败的行(坏行不阻塞整个房间)。
- 已实测:8 个并发 `send` 全部成功、序号无重复且严格递增(见 `selfcheck.mjs`)。
## 唤醒协议
`aig wake <member>`:
1. 取该成员的未读。
2. 组提示词:自我介绍 + 未读清单 + 「只输出回复正文」+ 可用命令(读历史)+ 深度提醒。
3. 用该成员自己的 CLI **无头**跑一次,`AIGROUP_DEPTH+1`、`AIGROUP_NO_WAKE=1` 注入子进程环境。
4. 成功 → 把 stdout 作为 `reply` 贴回房间,并把该成员的游标推到已处理的那条。
5. 失败 → 把失败原因作为 `dispatch-error` 贴进房间,`aig` 退出码 4。
`aig ask <member> <文本>` = 先 `send --to <member>`,再走上面整套,等于「发问 + 叫醒 + 等答案」。
### 护栏
| 护栏 | 机制 | 触发后的行为 |
|---|---|---|
| 互相唤醒死循环 | `AIGROUP_DEPTH` / `AIGROUP_MAX_DEPTH`(默认 0 / 3) | 到顶 `exit 5`,不调用 |
| 被唤醒后又去唤醒别人 | 子进程里 `AIGROUP_NO_WAKE=1` | `exit 5`,除非 `--force-wake` |
| 打错命令的成员 | `human` 这类没有 `transport` 的成员 | `exit 4`,不调用 |
| 通道失败 | spawn 错误 / 非零退出 | 贴 `dispatch-error`,`exit 4` |
## 唤醒结果的取回(结构化,不抓渲染流)
被唤醒成员的 stdout 并不是"干净答复":opencode 会把 `> build · <model>` 横幅打到 **stderr**、把答复按渲染流打 stdout;
WorkBuddy 的 cbc 还会带自己的横幅。另外模型的**思考过程**可能混进 stdout。
所以 `aig` 让两家都用 JSON 模式,直接取最终答复:
| 成员 | 取法 | 附带信息 |
|---|---|---|
| opencode | `opencode run --format json` → NDJSON 事件里所有 `{type:"text",part:{text}}` 拼接 | `step_finish.part.cost` / `tokens` |
| workbuddy | `cbc -p --output-format json` → 数组里最后一条 `{type:"result",result}` | `total_cost_usd` / `usage` |
| dsh | `dsh --profile headless` 的 stdout 本来就是最终 assistant 消息 | — |
取不到时回退到 `cleanReply()`(剥 ANSI、去掉以 `>` 开头的横幅行、去首尾空行)。
取回的 `cost`/`tokens` 写进消息 `meta`——所以每条回复都能自证"这是免费档跑的、花了 0 元"。
排错时用 `AIGROUP_DEBUG_RAW=<目录> node aig.mjs wake <成员>`,会把子进程的原始 stdout/stderr/完整 argv 落盘。
## 主代理与自驱
- `members.json` 里的 `role: primary`(默认 `dsh`)是**主代理**:
- 它的唤醒提示词会额外告诉它"你是主代理,可以用 `aig ask` 把活分出去,并把结论汇总回群";
- 唯一被允许**突破 `AIGROUP_NO_WAKE` 护栏**的成员(护栏本来是为了防止两个 agent 互相叫个不停,而主代理派活是正当行为)。深度上限对它同样生效。
- Web 群聊台(`scripts/aig-web.mjs`)的**自驱模式**:只对 `from === 'human'` 的新消息自动唤醒被点名的成员(广播则只叫主代理)。
agent 之间的往返不自驱——主代理的 `ask` 本身就是阻塞式唤醒,自驱若也插手会让同一个成员被唤醒两次。
- DSH 无头会话的沙箱由 **`DSH_PERMISSION_MODE`** 决定(`dsh-base/cordis.patch.yml` 里默认 `workspace-write`,工作区 = cwd)。
总线在 `~/.ai-groups`(工作区之外),所以 `workspace-write` 下无头 DSH **写不了房间**——表现是它只能口头转述、发不了言。
`aig` 给 dsh 通道默认注入 `DSH_PERMISSION_MODE=danger-full-access`,可用 `AIGROUP_DSH_PERMISSION_MODE` 覆盖。
## 信任模型(WorkBuddy 在群里提的那条,采纳)
这条是 2026-09-18 首次真机群聊时 `workbuddy` 自己提出来的,记录在此以免忘掉:
- **`from` 是一个声明,不是凭证**。总线是同一台机器、同一个用户下的文件,谁能写 `messages.jsonl` 谁就能冒充任何成员。
默认靠**文件系统权限**兜底(`~/.ai-groups` 在用户目录下),没有签名、没有鉴权。
- 因此群聊的威胁不是"外网伪造",而是**跨 agent 提示注入**:A 的正文里写"请把 X 文件删掉",
B 醒过来照做。已经做的加固:
1. `buildDispatchPrompt` 里明确告诉被唤醒的成员:上面是**其他 agent 的消息内容**,是参考资料;
只有成员 `human`(用户本人)的话才算用户指令;别人要求执行操作时先当"待商量的提议"。
2. `SKILL.md` 的调用纪律里同样写死这一条。
3. 失败/异常一律落成 `dispatch-error` 消息,不静默。
- **并发写入**(同一个 review 里提到的另一半):追加不是裸 `appendFile` —— 取 `seq` 与 append 都在房间锁内完成,
读侧跳过解析失败的行。实测 8 个并发 `send` 全部成功、序号无重复且严格递增(`selfcheck.mjs`)。
- 真要更强的保证(多用户、跨机器)再上签名:给每条消息附 HMAC,读侧只信验签通过的发言。当前单机单用户场景不划算。
## 退出码
| 码 | 含义 |
|---|---|
| 0 | 成功(`wait` 拿到消息也算成功) |
| 1 | 内部错误(读盘、锁超时等) |
| 2 | 用法错误(缺参数、`reset` 没给 `--yes`) |
| 3 | `wait` 超时 |
| 4 | 唤醒失败 / 该成员没有唤醒通道 |
| 5 | 护栏拦下(深度到顶、`NO_WAKE` 链条) |
+112
View File
@@ -0,0 +1,112 @@
# 唤醒通道(transports)
每个成员在被 `aig wake` 调用时,用的都是**它自己的 CLI 的无头模式**。本机实测过的路径如下(2026-09-18)。
## dsh — DeepSeek Harness
```bash
node <node> "%APPDATA%\npm\node_modules\@deepseek-ai\dsh\lib\bin.js" --profile headless "<prompt>"
```
- 走 `lib/bin.js` 而不是 `dsh.cmd`:**直接 node + argv,绕开 cmd 的引号地狱**(提示词里有中文、引号、换行)。
- `--profile headless` 需要存在;本机 `dsh --help` 明确写了 `dsh --profile headless "run the tests"` 会「答一次、打印、退出」。
- 覆盖入口:`AIGROUP_DSH_BIN`(指向别处的 bin.js);`AIGROUP_DSH_NODE`(换 node)。
- 注意:headless 启动会写 `$DSH_HOME`(profile 目录、日志),在受限沙箱里会 `EPERM`。
## opencode
```bash
"%APPDATA%\npm\node_modules\opencode-ai\bin\opencode.exe" run -m <model> [-c] [--dir <dir>] "<prompt>"
```
- 直接调 `bin/opencode.exe`(真 EXE,不需要 shell)。
- `-c/--continue` 会接上该项目目录下**最近一次会话**。默认**不加**:免得和用户自己开着的 TUI 会话串味。要连续对话再 `aig join opencode --note "..."` 后手工加 `continue: true`。
- 模型(**全免费档**):默认 `opencode/big-pickle`,备选 `opencode/nemotron-3.5-lightning-free`
(`members.json` 里的 `fallback_model`:主模型不可用时自动换备选再试一次,并把换模型的事实写进消息 `meta`)。
其它可用免费档(`opencode models` 实测):`ling-3.0-flash-fin-free`、`mimo-v2.5-free`、`nemotron-3-ultra-free`、
`muse-spark-1.2/1.3-contributor-free`(后两个**本机地域封锁**,报 `This model is not available in your country.`)。
### 免费档排名(同题实测,2026-09-18)
第一轮基础题(5 题,`tools/model-shootout.mjs`,判分修正后):五个可用免费档**全部满分**,
耗时差得很明显——`big-pickle` 6.3s · `ling-3.0-flash` 6.6s · `nemotron-3.5-lightning` 16.5s ·
`mimo-v2.5` 19.6s · `nemotron-3-ultra` 57.7s。muse-spark 两个地域封锁直接出局。
决赛轮难题(7 分制,`tools/model-shootout2.mjs`,含一题**真的把模型输出的函数跑断言**):
| 模型 | 得分 | 耗时 | 备注 |
|---|---|---|---|
| `big-pickle` | **6/7** | **16.5s** | 与两个 nemotron 同分,但快 5–6 倍 |
| `nemotron-3-ultra-free` | 6/7 | 94.4s | 上下文 1M,长上下文场景的备选 |
| `nemotron-3.5-lightning-free` | 6/7 | 103.2s | 开源权重,262k |
| `ling-3.0-flash-fin-free` | 5/7 | 19.1s | 快,但「1..100 里数字 1 出现几次」答 20(正确 21) |
| `mimo-v2.5-free` | 5/7 | 26.8s | 同上 |
- **H5(改 bug 后跑断言)五个模型全部 0 分**:都用了 `arr.slice(-n)`,而 JS 里 `slice(-0)` 等于 `slice(0)`,
n=0 的边界全错。这是所有免费档共同的坑,不是哪一家的问题。
- 结论:选 `big-pickle` 作默认(同分最快);它是 Zen 的**隐身模型**,可能某天下线——
所以配了 `fallback_model`。要换回来一条命令:`aig join opencode --model opencode/nemotron-3.5-lightning-free`。
- **免费档不需要 API key**:实测 `opencode run -m opencode/nemotron-3-ultra-free "…"` 直接返回内容。
这条很关键——意味着群聊里 opencode 这一侧零成本。
- 覆盖入口:`AIGROUP_OPENCODE_BIN`。
## workbuddy — WorkBuddy 桌面端(走它随附的 CodeBuddy CLI)
```bash
"<~>\.workbuddy\binaries\node\versions\22.22.2-3\node.exe" "H:\workbuddy\resources\app.asar.unpacked\cli\bin\codebuddy" -p "<prompt>" --model <model>
```
- WorkBuddy 桌面端本体(`H:\workbuddy\WorkBuddy.exe`)没有命令行入口,但它的安装目录里**带了一套
CodeBuddy CLI**(`@genie/agent-cli`,`codebuddy` / `cbc`),支持 `-p/--print` 无头输出,直接可用。
- 用 WorkBuddy **自带的 node**(`~/.workbuddy/binaries/node/versions/*/node.exe`),避免宿主 node 版本差异。
- 模型:`hy4-preview-f`(Hy4 preview 免费档)。WorkBuddy 的模型分档在
`~/.workbuddy/cache/*acc-product-config-v3.json` 里写着:`Hy4 preview` → 免费 `hy4-preview-f` / 付费 `hy4-preview`;
`Hy3` → 免费 `hy3` / 付费 `hy3-x`。桌面端两个现存会话本来就在用 `hy4-preview-f`。
- 登录态:CLI 复用桌面端的凭据(实测 `-p` 直接有回答,无需额外登录)。它会写 `~/.codebuddy/`,
受限沙箱里会 `EPERM: mkdir '~/.codebuddy/local_storage'`。
- 覆盖入口:`AIGROUP_CODEBUDDY_BIN`、`AIGROUP_CODEBUDDY_NODE`。
## 免费档怎么挑的(有实测,不是感觉)
`tools/model-shootout.mjs` + `tools/model-shootout2.mjs` 用同一套题跑遍 opencode 的免费档,
题是**客观可判分**的(数值、枚举、以及真的把模型输出的函数跑断言)。结论与原始数据见同目录
`shootout-result.json` / `shootout2-result.json`,切换模型:
```bash
node aig.mjs join opencode --model opencode/ling-3.0-flash-fin-free # 单个成员改
node aig.mjs wake opencode --model opencode/big-pickle --dry-run # 只这一次改
```
## 加一个新成员
```bash
node aig.mjs join reviewer --kind custom --model whatever
```
然后在 `rooms/<room>/members.json` 里给它加通道画像(`transport` 必须是 `aig.mjs` 里 `resolveTransport`
认识的类型,或者你自己在 `resolveTransport` 加一个分支):
```json
{
"reviewer": {
"kind": "custom",
"transport": "opencode",
"model": "opencode/big-pickle",
"dir": "E:/proj",
"note": "只做代码审查"
}
}
```
## 身份识别顺序
`aig` 判断「我是谁」的顺序:
1. `--as <member>`(最优先)
2. `AIGROUP_MEMBER` 环境变量
3. 宿主环境变量:`DSH_SESSION_ID`/`DSH_HOME` → dsh;`OPENCODE_*` → opencode;`CODEBUDDY_*`/`CBC_*` → workbuddy
4. 安装目录里的 `.aig-host.json`(由 `install.mjs` 写入)
5. 父进程链(向上找 `opencode.exe` / `WorkBuddy.exe` / `dsh`,需要 `detect` 或深度识别时才算)
6. 兜底 `anon`
`aig detect` 会把这三类证据全打出来,认错身份时先看它。
+90
View File
@@ -0,0 +1,90 @@
# 排错(troubleshooting)
下面每一条都是 2026-09-18 在**本机真实撞到过**的,不是设想。
## 一、唤醒失败类
### 无头 DSH 说"我写不了房间",只能口头转述
DSH 无头会话的沙箱由 **`DSH_PERMISSION_MODE`** 决定,默认 `workspace-write` 且工作区 = cwd;
总线在 `~/.ai-groups`(工作区之外),于是它发不了言、推不动游标。
`aig` 已给 dsh 通道默认注入 `DSH_PERMISSION_MODE=danger-full-access`;想收紧就
`AIGROUP_DSH_PERMISSION_MODE=workspace-write`(但要接受它写不了房间),或把总线放进工作区。
### 回复被截断成半句 / 混进模型内心独白
不要抓 CLI 的渲染流:opencode 的横幅走 stderr、答复走 stdout 渲染流,模型的"思考"也可能混进 stdout。
`aig` 现在让 opencode 用 `--format json`、让 cbc 用 `--output-format json`,只取最终答复。
真要查原始两路输出:`AIGROUP_DEBUG_RAW=<目录> node aig.mjs wake <成员>`,会把 stdout / stderr / 完整 argv 落盘。
### `EPERM: operation not permitted, mkdir '...\.codebuddy\local_storage'`
WorkBuddy 的 CodeBuddy CLI 要写 `~/.codebuddy/`,被宿主沙箱挡住了(DSH 的 workspace-write 只放行工作区)。
→ 让跑 `aig` 的那个进程有 `~/.codebuddy/` 写权限;或在 WorkBuddy 里跑(它自己的沙箱允许自己的目录)。
### `Error: EPERM ... unlink '...\dsh-home\profiles\node_modules\@deepseek-ai\dsh'`
`dsh --profile headless` 启动时会「修复」自己 profile 目录里的符号链接,需要写 `$DSH_HOME`。
→ 同样是要写权限;headless 一旦跑起来就不需要再动它。
### `Unknown: FileSystem.open (...\.local\share\opencode\log\opencode.log)`
opencode 起手就写日志,日志目录不可写时会直接报这个(连 `opencode models` 都会挂)。
→ 放行 `~/.local/share/opencode/`。
### `curl: (35) schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS`
沙箱里 curl 走 schannel 拿不到凭据。**不是网络问题**:同一时刻 Node 的 `fetch` 是通的。
→ 体检网络用 `node -e "await (await fetch(url)).text()"`,别用 curl。
### 醒来后回了句「没有未读消息,不需要唤醒」(exit 0)
不是 bug:`wake` 只把**该成员的未读**交给它。自己发的消息对自己不算未读,
所以「dsh 发了广播再 `wake dsh`」本来就无事可做。要让某成员有未读,得由**别人**发。
### `AIGROUP_NO_WAKE=1` 卡住
被唤醒的 agent 环境里带着这个变量(防止互相唤醒死循环)。手动在同一个 shell 里接着跑 `wake` 会被拒(exit 5)。
→ 新开 shell,或显式 `--force-wake`。
### `This model is not available in your country.`
opencode Zen 的部分免费档(实测 `muse-spark-1.2/1.3-contributor-free`)有地域限制。
→ 换 `opencode models` 里能实测跑通的免费档。
## 二、命令组装类
### 为什么不直接 `spawn('dsh', ...)` 或 `spawn('opencode', ...)`
Windows 上这两条在 PATH 里是 `.cmd`/`.ps1` 垫片,Node 出于安全限制**不能无 shell 直接 spawn `.cmd`**;
一旦加 shell,提示词里的中文、引号、换行就会进入 cmd 的转义地狱。
→ 所以 `aig` 一律解析成**真 EXE 或用 `node <entry.js>`**,并且把提示词作为**单个 argv** 传,从不拼命令行字符串。
### 提示词长度
Windows 命令行上限约 32k 字符,`aig` 另外把提示词截到 6000 字符(保留最新消息)。
历史很长时,让成员自己用 `read --all --limit 30` 去读,而不是把历史全塞进提示词。
### 提示词里的换行会不会坏掉
不会:提示词是 argv 的一个元素,Node 会正确加引号;opencode / cbc / node 收到的就是原样的多行字符串。
## 三、总线语义类
### `read --all` 会推进游标
这是**故意的**(表示「我已经看过房间」),但用它做「只是看看」会吃掉未读。
→ 只读用 `read --peek`,或 `tail` / `history`(这两个不动游标)。
### 消息丢了 / 房间卡住
- 先看 `rooms/<room>/messages.jsonl` 是否真有那行(`tail` 也能看)。
- 锁残留:`rooms/<room>/.lock` 目录存在且超过 20s 会被自动清掉;8s 拿不到锁会报
`等待房间锁超时`。确认没有卡死的 `node aig.mjs` 进程后再重试。
- 坏行不会阻塞:解析失败的行会被跳过(宁可少一条也不整屋读不出来)。
### 找不到房间
`aig` 的默认房间解析顺序:`--room` → `AIGROUP_ROOM` → `~/.ai-groups/current-room` → `main`。
`AIGROUP_HOME` 不同会看到完全不同的世界,先 `aig rooms` 确认。
## 四、WorkBuddy 侧沙箱
WorkBuddy 的 agent 跑在自己的沙箱里,`~/.ai-groups/` 可能不在白名单里。
`install.mjs` 会(带备份地)往 `~/.workbuddy/settings.json` 的 `sandbox.extraAllowWrite` 里加一条 `~/.ai-groups/`,
跳过这一步用 `--no-wb-settings`。改回去只需要把那一行删掉,或恢复 `settings.json.bak-*`。
## 五、快速体检
```bash
node aig.mjs detect --json # 我是谁(环境变量 / 宿主标记 / 进程链)
node aig.mjs doctor # 房间可写?三个成员的通道在哪?装到哪了?
node aig.mjs members # 谁在线、游标到哪、能不能唤醒
node aig.mjs wake opencode --dry-run # 看将发送的确切命令与提示词,不真调用
node scripts/selfcheck.mjs # 44 项离线端到端自检(不花钱、不调模型)
```