---
name: os-channel
description: CC 与 Codex（或两个各自独立运行的 CC 会话）之间用 OS 信道留言、查未读、被新消息唤醒。触发：要跨 harness 把结论交给对端、给对端留言或清未读、用户问"能不能让 Claude 和 Codex 互相沟通"。不适用于派子 agent 或 workflow 内部协作——那是 Agent 与 SendMessage，信道对同会话的子 agent 没有意义。
---

# OS Channel — 让另一个 AI 找得到你

两个 AI 会话各自独立、互不可见。这个信道让它们能留言、能知道有人叫自己、甚至能被
新消息叫醒。

## 三条能力，边界要说准

| | CC | Codex |
|---|---|---|
| 发信 / 主动读信 / 标记已读 | ✅ 实测 | ✅ 实测 |
| **开口时自动提示未读** | ✅ 实测 | ✅ 实测（2026-09-09，CLI 与桌面端各一次）：提示在任何工具调用之前进入模型上下文 |
| **没人开口时也能收到消息** | ✅ 实测：**事件驱动**，武装 watcher 后约 8 秒 | ✅ 实测：**显式等待**，会话调 `channel_wait` 挂起，来信即返回；没在等的空闲会话不会被叫醒 |
| 会话已退出后被叫醒 | ❌ | ❌ |

两侧第三行的机制**根本不同，不要混为一谈**：

- CC 是**消息把会话叫醒**——watcher 发现有人点名就退出，后台任务退出触发 harness 重新
  调起模型。延迟取决于轮询间隔（默认 8 秒）。
- Codex 是**会话自己决定等**——调 `channel_wait` 后挂在 WS 上，来信即返回，到期则返回末次
  补读结果。响应里的 `delivery_source` 说明走的是哪条路：`replay`（一开始就有信）、
  `event`（等到推送后补读）、`timeout_read`（到期补读，可能有信也可能为空）。没有调
  `channel_wait` 的空闲会话什么都收不到，这一点与 CC 的 watcher 不对等。

（Codex 侧 hook 要让模型看见输出必须走 `hookSpecificOutput.additionalContext`，纯文本
stdout 不注入——这条属 Codex hook 开发面，见 `plugin/harness/codex/`。）

**失效模式也不同类，排障时先分清是哪一种**：

- CC 会**整体停摆**：进程被环境挤掉、忘了重新武装、会话消亡。特征是从某一刻起完全没
  反应，容易发现。
- Codex 是**等或不等在模型手里**：`channel_wait` 是它自己决定调的，指令躺在上下文里不等
  于会被执行；没调就等于没在收。另有到期返回空页的正常情况（`status=timeout`），别当
  成故障。

所以「对方没回」不能直接推成「对方没收到」，两种形态要用不同办法确认。

两条纪律：

- **最后一行是硬天花板**：会话退出后模型不在运行，没有任何东西能接收唤醒。跟用户描述
  时不要含糊成"实时通信"。
- **两侧能力不对称，不要按 CC 的结论描述 Codex**。上表前三行两侧都有真机背书，但机制
  不同：描述 Codex 时说"开口时会被提示、可以显式挂起等信"，不要说"消息会把它叫醒"。
- **CC 的 8 秒是检查频率，不是端到端延迟**；Codex 的 `channel_wait` 是显式挂起，快慢取决
  于它有没有在等。给用户解释时别用这些数去承诺一个确定的延迟，也别说"实时互通"。

## 1. 约定身份和频道

- **reader 是角色标识**（`leader-cc` / `leader-codex`），**不是 session_id**。会话是一次性
  的，按会话记已读水位会让每开一个新会话就把全部历史消息重算成未读。
- 用**专线频道**（如 `team:<项目名>-bridge`）而不是 `project:<id>` 频道——后者是项目级
  广播，同项目的其他会话都会读到。频道名只过格式校验，不要求 team 真实存在。

## 2. 发信

```
channel_send(
    channel="team:aiteam-os-bridge",
    message="...",
    sender="leader-cc",
    mentions=["leader-codex"],     # 不写 mentions 对方就不会被提示
    project_id="<项目 id>",        # 你自己解析不到项目时必须显式传，见下
)
```

- `mentions` 里裸名与 `@名` 都算数，未读判定两种都认
- `project_id` 留空时按**发送方自己的工作目录**自动归属（cwd 最长前缀匹配），与收件方在
  哪运行无关。现行纪律下 worktree 一律建在仓库内 `.worktrees/`，属子目录，能正常解析；
  只有 cwd 落在所有已注册项目路径之外（例如 worktree 被建到了仓库同级目录）才解析不到。
  拿不准就先 `context_resolve`。
- **归属为空的消息照发照存，但不进任何人的未读**——收件人不会被提示，只能主动读到。
  这是刻意的取舍（拒收会打死所有不用未读功能的历史调用方），但代价是漏传 project_id
  这件事不会报错，只会让消息静悄悄地谁也不通知。

## 3. 读信与清零

```
channel_read(channel="team:aiteam-os-bridge")        # 纯读，不清未读
channel_read_ack(                                    # 清零
    channel="team:aiteam-os-bridge",
    reader="leader-cc",
    project_id="<项目 id>",                          # 同上：你解析不到项目时必须传
    last_read_at="<你实际读到的最后一条的 created_at>",
)
```

`last_read_at` 填**你本次实际读到的最后一条的 `created_at`**（工具描述与每轮注入的未读
提示行里都已给出这个口径和拼好的 ack 命令）。

### 不是你的事，就别清零

水位按 `(reader, channel, project_id)` 记，而**同一端的所有会话共用同一个 reader**。所以
你一 ack，同一 harness 的其他会话的徽章**一起消失**——那条消息对它们从此隐形，除非有人
主动 `channel_read` 去翻。

这是刻意的取舍：按会话记水位会让每开一个新会话就把全部历史消息重算成未读，那更糟。
代价就落在纪律上：

- 读到明显是发给**另一个会话**的消息（比如点名的是另一份工作、或内容属于别人手上的
  任务），**读了就好，不要 ack**，把它留给该处理的那个会话
- 只有"这件事归我处理"时才推进水位
- 拿不准就别 ack。重复看到一条消息是噪音，让它对所有人消失是丢消息

## 4. 被新消息叫醒（可选，需要武装）

默认情况下，对方发的消息要等**下次有人跟你说话**时你才会看到。要让它主动叫醒你：

```
bash scripts/os-watch.sh <session_id> <team_id> <reader>     # 必须以宿主的后台任务方式起（run_in_background）
```

watcher 每 8 秒问一次 `GET /api/wake/actionable`，发现有人点名你就退出，而后台任务
退出会让 harness 重新调起你。实测：对端发消息后约 8 秒内自动醒来，用户零输入。

约束：
- **随会话消亡**：父进程没了即自判孤儿退出（日志 `WATCHER_ORPHANED`）；另有 12h 硬超时
  兜底（`OS_WATCH_MAX=43200`，2026-09-08 由 1h 上调）。它不是常驻服务；触发一次即退出，
  醒来后要重新武装
- **只能用宿主的后台任务方式起**：在前台 shell 里 `nohup … &` 起的，父 shell 一退它就自判
  孤儿退出（日志 `WATCHER_ORPHANED`），看起来像"又被杀了"
- 第三个参数 `reader` 不传就不把信道点名算作唤醒信号
- `OS_WATCH_SIGNALS=mentions` 可只盯信道点名；跑 workflow 时必开，否则每条子 agent 的
  进展 memo 都会叫醒你
- 判据是"**新到达**"而非"当前未读"：读了但没清零的消息不会让它反复唤醒你

## 检查有没有人叫你

```
channel_unread(reader="leader-cc")      # 纯读，不会清掉未读
```

