# 收件箱、团队与进度检查

> 持久化的 inbox 表承载后台 actor 的通知、权限转发与追加指令：写入与唤醒、排空成合成消息、渲染格式，以及残存的 Team 模块与进度日志检查。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/inbox-team
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

上一篇的 `spawn` 立即返回，子智能体在后台跑完，结果得有路回到父级；Orchestrator 的子会话要请求审批；父级还要给已经闲下来的子智能体追加任务。MiMo Code 给这些消息修了同一条路：一张持久化的 `inbox` 表。发送方只管写一行并叫醒收件方，收件方的主循环每次迭代开头把收件箱排空，变成一条合成的 user 消息。上游 v1.14.19 的 `packages/opencode/src` 下没有对应的模块。

## 怎么用

- **模型发消息**：`actor send <to_actor_id> "<content>" [--session <id>] [--type <t>]`，收件方可以是同一会话的子智能体（如 `explore-1`）、某个会话的 `main`，或用 `--session` 指定的别的会话（`packages/opencode/src/tool/actor.ts:577`）。收件方不存在时不抛错，而是返回 `inboxID` 为 null 并附 `receiver not found`。Orchestrator 另有 `session send`，见下一篇。
- **模型收消息**：普通消息被包成 `<inbox from="发送会话:发送者" sent_at="…">`；运行时生成的通知是一段 `<actor-notification>`，两者都作为 user 消息出现在下一次迭代里。`actor send` 填的发送者是调用方的 agent 名（`actor.ts:590`），不是 actor ID。
- **用户看到的**：后台子智能体完成、失败、取消、停滞都会弹 toast；开了 Orchestrator 实验开关时，TUI 还会把 `<actor-notification>` 还原成一张卡片（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1618`）。
- **清理**：没有配置项；Inbox 服务初始化时删除 7 天前的行（`packages/opencode/src/inbox/inbox.ts:18`、`inbox.ts:140`）。

## 表与服务

`inbox` 表（`packages/opencode/src/inbox/inbox.sql.ts:5`）以 ulid 为主键，收件方是 `receiver_session_id` 加 `receiver_actor_id`，发送方两列可空，`type` 默认 `text`，正文以 JSON 存成带 `text` 字段的对象，外加 `created_at`。索引建在“收件会话、收件 actor、id”上（`inbox.sql.ts:21`），ulid 按时间排序，排空时按 id 取出，大体就是到达顺序。`Inbox` 服务只有 `send` 与 `drain` 两个方法（`inbox.ts:120`）。

## 消息从哪来

| 来源 | type | 收件方 | 位置 |
| --- | --- | --- | --- |
| 后台 actor 首轮结束（完成或失败），checkpoint-writer 除外 | `actor_notification` | 父会话里的 `parentActorID`，缺省 main | `packages/opencode/src/actor/spawn.ts:429` |
| 后台 actor 被取消 | `actor_notification` | 同上 | `spawn.ts:912` |
| 停滞看门狗 | `actor_notification` | 同上 | `spawn.ts:990` |
| 被唤醒的 peer 跑完一轮 | `actor_notification` | 父会话 | `packages/opencode/src/session/prompt.ts:4895` |
| Workflow 运行结束 | `actor_notification` | 所在会话的父 actor | `packages/opencode/src/workflow/runtime.ts:1419` |
| 子会话的权限请求被转发 | `text` | 父会话的 main | `packages/opencode/src/permission/index.ts:417` |
| `actor send` | 默认 `text` | 任意已登记的 actor | `actor.ts:585` |
| `session send`，以及 `session create --topic` 命中已有子会话 | `text` | peer 子会话 | `packages/opencode/src/tool/session.ts:893`、`tool/session.ts:752` |

取消与停滞通知只在对象是后台 subagent 或 peer 时发出，并排除 checkpoint-writer、dream、distill 这些运行时派生的智能体。Workflow 的通知虽然 type 也是 `actor_notification`，正文却是 `Workflow completed. run_id: …` 这样的纯文本，没有 `<actor-notification>` 外壳。

## send：写一行，再叫醒

```mermaid
sequenceDiagram
  participant S as 发送方
  participant I as Inbox.send
  participant T as inbox 表
  participant L as SessionPrompt.loop
  participant R as 收件方主循环
  S->>I: 收件方、正文、type
  I->>I: 查注册表，收件方不存在就失败
  I->>T: 插入一行，发布 inbox.arrived
  I-->>L: fork 唤醒，带 notifyParentOnComplete
  I-->>S: 返回 inboxID
  L->>R: 空闲则开跑，忙则等待当前运行
  R->>T: 每次迭代开头 drain
  R->>R: 合成 user 消息进入本轮上下文
```

`send` 先做存在性检查（`inbox.ts:145`）：收件方必须在 `actor_registry` 里有行，否则失败为 `InboxReceiverNotFound`。所以 peer 要在 `session create` 返回前同步登记，每个会话自带的 main 行也在这里派上用场。随后插入、发布 `inbox.arrived` 事件，再 fork 一个唤醒（`inbox.ts:178`）：

```ts
      const promptRef = sessionPromptRef.current
      if (promptRef) {
        yield* promptRef
          .loop({
            sessionID: input.receiverSessionID,
            agentID: input.receiverActorID,
            // Woken turns notify their parent on completion. The spawn turn goes
            // through SessionPrompt.prompt (no flag) so forkWork.notify remains
            // the sole notifier for turn 1 — no double-notify.
            notifyParentOnComplete: true,
          })
          .pipe(Effect.ignore, Effect.forkIn(scope))
```

唤醒 fiber 挂在 Inbox 服务自己的作用域里，发送方返回后照样送达。`sessionPromptRef` 是一个模块级的后绑定引用（`packages/opencode/src/inbox/inbox-ref.ts:29`），由 `SessionPrompt` 初始化时填入（`prompt.ts:5326`）：主循环每次迭代都要调 `inbox.drain`，Inbox 若再以普通 Layer 依赖 SessionPrompt 就成环了。同样的手法还有 `inboxServiceRef`（actor 与 session 工具借它拿 Inbox，`inbox-ref.ts:65`）和 `defaultModelRef`（`inbox-ref.ts:51`）。

`SessionPrompt.loop` 按 `sessionID:agentID` 找运行器（`prompt.ts:4923`）：空闲就开跑一个新的主循环；已经在跑，就只等待当前这次运行（`packages/opencode/src/effect/runner.ts:114`），新行留在表里，等下一次迭代开头排空。

有一点要注意：把 actor 标成 running、结束后写回结局的，只有上一篇的 `runTurn`（`packages/opencode/src/actor/turn.ts:16`），而经收件箱唤醒的轮次走 `SessionPrompt.loop`，不经过它。按代码读，一个已经 idle 的 actor 被唤醒后，注册表状态在这一轮里不会翻回 running：停滞看门狗不会盯它，`session status` 与 `session join` 看到的仍是上一轮的结局。

## drain：变成一条合成 user 消息

主循环每次迭代开头、读取本切片历史之前，先排空自己的收件箱（`prompt.ts:3624`）。`drain`（`inbox.ts:201`）的步骤：

1. 按 id 顺序取至多 `MAX_DRAIN_PER_TURN` 即 100 行（`inbox.ts:19`），一行没有就立即返回。
2. 决定合成消息挂在哪个 agent、用哪个模型（`resolveDrainSeed`，`inbox.ts:64`）：先找最近一条真实的、带模型的 user 或 assistant 消息，普通收件方只在自己切片里找，peer 跨切片找；找不到就用注册表行里的 agent 加项目默认模型；仍然没有则不写消息，行留在表里等下次。
3. 逐行渲染，渲染出空白的行丢弃并记日志；全部空白就只删行、不写消息（`inbox.ts:261`、`inbox.ts:277`）。空白的 user 文本部件会被 AI SDK 过滤掉，发给提供方时变成空内容而被拒。
4. 写一条 `agentID` 为收件方的 user 消息，每行一个 `synthetic: true` 的文本部件（`inbox.ts:297`），然后删除这些行。写消息与删行不在一个事务里，注释承认崩溃在两者之间会让下次重复注入同样的通知（`inbox.ts:289`）。

写入的是新的 user 消息，主循环于是把它当作本轮输入继续跑；这也是“闲下来的子智能体收到 `send` 就接着干”的全部机制。

## 渲染成什么样

`renderInboxRow`（`packages/opencode/src/inbox/render.ts:13`）只分两类：`actor_notification` 的正文由发送方预先渲染，原样放行；其余类型一律包成带发送者与时间的 `<inbox>` 元素（`render.ts:24`）：

```ts
  const sender = row.sender_session_id
    ? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}`
    : "system"
  const sentAt = new Date(row.created_at).toISOString()
  return `<inbox from="${sender}" sent_at="${sentAt}">\n${blankTo(content.text, "(empty)")}\n</inbox>`
```

转发来的权限请求只填了发送会话，所以显示为 `from="子会话ID:?"`，正文是一段 `<permission-request>`，告诉 Orchestrator 用 `session approve` 或 `session grant-approval` 处理。

通知正文由 `renderActorNotification`（`render.ts:31`）生成，首行固定以 `Background sub-session "描述" (actor_id: …)` 开头，接下来的措辞按情形变化：

| 情形 | 首行结尾 | 后续行 |
| --- | --- | --- |
| 完成，自报 success、partial 或没报 | `completed.` | 有自报时的 `Status:`，以及 `Summary:`、`Result:` |
| 完成，自报 failed 或 blocked | `finished (status: failed).` 之类 | `Summary:`、`Result:` |
| 完成，自报值无法识别 | `ended (status not reported).` | `Summary:`、`Result:` |
| 失败 | `failed.` | `Error:` |
| 停滞 | `appears stalled (no activity for Ns).` | 建议查看、催促或取消 |
| 取消 | `was cancelled.` | — |

之所以区分“进程正常结束”和“任务是否成功”，是为了不让父级把一次自报失败读成完成（`render.ts:47` 的注释）。TUI 用反向的 `parseActorNotification`（`render.ts:101`）还原出状态、描述与一行摘要。`render.ts:86` 起的注释说 stalled 留给将来的看门狗、目前不会产生，已经过时：看门狗早就在发（`spawn.ts:990`）。

## 唤醒后的回报

`forkWork` 的通知只包住 spawn 那一轮。持久的 peer 闲下来后被 `send` 唤醒，跑完新的一轮时，主循环在结尾检查 `notifyParentOnComplete`：本次是收件箱唤醒的、会话有父会话、自己是后台 peer 且不是系统智能体，就用同样的格式给父会话投一条完成或失败通知（`prompt.ts:4883`）。首轮不带这个标志，不会重复通知；Orchestrator 因此不必轮询子会话。

条件里写死了 `actor.mode === "peer"`，还要求会话有 `parentID`。按代码，同会话的 subagent 被 `actor send` 唤醒后跑完的那一轮不会通知父级，这与 `packages/opencode/src/tool/actor.txt:126` 对 `send` 的说明不符：

> The woken turn's result reaches you as a completion notification, not from the `send` call.

## 团队：只剩一个空壳

`packages/opencode/src/team/index.ts:13` 定义的 `Team` 服务只有五个方法：`create`、`addMember`、`removeMember`、`getMembers`、`teamDir`，数据是项目目录下 `.mimocode/teams/<团队 ID>/members.json` 里的成员数组（`team/index.ts:36`），成员记录会话 ID、agent、角色与加入时间。事件定义了 `team.created` 与 `team.member.joined`（`packages/opencode/src/team/events.ts:5`、`team/events.ts:13`），但只有前者在 `create` 里发布（`team/index.ts:69`），`addMember` 并不发后者；`packages/opencode/src/team/schema.ts:7` 的 `TeamMessage` 在 src 里也没有使用者。

源码里除了图层装配（`prompt.ts:5367`、`packages/opencode/src/tool/registry.ts:547`）找不到任何调用方。测试 `packages/opencode/test/team/migrate-to-inbox.test.ts:97` 断言 `tool/team.ts` 已删除，`migrate-to-inbox.test.ts:107` 断言 `sendMessage`、`checkMessages`、`readState`、`writeState` 不再存在：团队间收发消息那一套已经迁到收件箱，剩下的成员名册暂时没有入口。

## 进度检查：两种“盯”法

**运行中**靠上一篇的停滞看门狗：后台子智能体静默超过 6 分钟，父级收到一条 `appears stalled` 通知。

**结束时**靠内置插件 `SubagentProgressCheckerPlugin`（`packages/opencode/src/plugin/subagent-progress-checker.ts:102`，登记在 `packages/opencode/src/plugin/index.ts:156` 的内部插件列表里）。它挂在 `actor.postStop` 上，检查绑定了任务的子智能体有没有留下进度日志：

- **匹配范围**：用 `excludeOnly` 排除 checkpoint-writer、title、summary、dream、distill、compaction、main（`subagent-progress-checker.ts:113`）。默认 matcher 会跳过 general、explore 这些内置类型，而它们恰恰是最常绑定任务的子智能体，所以这里特意改写。
- **何时跳过**：spawn 时没传 `task_id`（`subagent-progress-checker.ts:127`）；agent 自身规则禁止写入，`canWrite` 为 false（`subagent-progress-checker.ts:132`，由 `spawn.ts:472` 按 agent 权限算出）；配置了 `memory.disable_write: true`，这时写入会被路径护栏拒绝，催也没用（`subagent-progress-checker.ts:139`）。
- **检查什么**：文件在 `<数据目录>/memory/sessions/<会话 ID>/tasks/<TID>/progress.md`（`packages/opencode/src/session/checkpoint-paths.ts:84`），必须包含 5 个标题 `## §1 Task identity` 到 `## §5 Outcome and discoveries`（`subagent-progress-checker.ts:11`）。
- **不合格**：文件不存在或缺标题时，返回 `continue` 并附上完整模板作为理由，子智能体据此再跑一轮去补写（`subagent-progress-checker.ts:151`、`subagent-progress-checker.ts:157`），至多 3 次。
- **合格**：插件往文件头写入 `written-at` 时间戳的 frontmatter（`subagent-progress-checker.ts:164`），不再要求重跑。这份日志随后由检查点写手读取、并入会话检查点，见 [检查点写手](https://daiw.org/manual/mimo-code/checkpoint-writer) 与 [树状任务](https://daiw.org/manual/mimo-code/tasks)。

缺文件时，催促的理由以这一句开头（`subagent-progress-checker.ts:48`）：

> Before terminating, write the task progress journal to:

下一篇：[Orchestrator：一个会话管所有会话](https://daiw.org/manual/mimo-code/orchestrator)——把 peer 子会话、收件箱与权限转发组合起来，用一个会话调度所有工作。
