os-channel · diff

git:20260909.0725b6c to git:20260914.98e9601

14 added, 28 removed. Audit A to A.

---
name: os-channel
- description: 让两个 AI 会话(CC 与 Codex,或同一 harness 的两个会话)通过 OS 信道互相发消息、收未读提示、被新消息唤醒。当用户想让两个 AI 协作、需要跨 harness 传递结论、或问"能不能让它们互相沟通"时使用。
+ description: CC 与 Codex(或两个各自独立运行的 CC 会话)之间用 OS 信道留言、查未读、被新消息唤醒。触发:要跨 harness 把结论交给对端、给对端留言或清未读、用户问"能不能让 Claude 和 Codex 互相沟通"。不适用于派子 agent 或 workflow 内部协作——那是 Agent 与 SendMessage,信道对同会话的子 agent 没有意义。
---
# OS Channel — 让另一个 AI 找得到你
两个 AI 会话各自独立、互不可见。这个信道让它们能留言、能知道有人叫自己、甚至能被
新消息叫醒。
- ## 什么时候用
-
- - 两个会话分头做一件事的两半(例如一侧写核心、另一侧写适配层),需要对齐接口
- - 一侧的结论要交给另一侧,而对方现在不在跑
- - 用户问"能不能让 Claude 和 Codex 互相沟通"
-
## 三条能力,边界要说准
| | 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 侧开口提示曾长期验不通,根因是**宿主不注入纯文本 stdout**:hook 执行了、审计里有
- 输出,模型却看不到。改为 `hookSpecificOutput.additionalContext` JSON 后一次验通。"文档
- 声称可交付"与"真机验证可用"之间的落差,在本功能开发中连着误判过三次——已验和未验
- 必须分开写。
+ (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 能不能解析出项目——典型的解析不到的情况是你跑在
- git worktree 里:归属只认精确路径与子目录,而 worktree 是同级目录,两条都不命中。
+ - `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` **取你实际读到的最后一条**,不要填摘要里的"最新时间"。分页只取回前 N
- 条时填了最新时间,剩下的会被一起标成已读,从此不再提示。
+ `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 秒内自动醒来,用户零输入。
约束:
- - **随会话消亡**,1h 硬超时后要重新武装。它不是常驻服务;触发一次即退出,醒来后要重新武装
+ - **随会话消亡**:父进程没了即自判孤儿退出(日志 `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") # 纯读,不会清掉未读
```
- ## 给用户解释时的准确说法
-
- > 两个 AI 可以互相留言,而且不用等你开口就能收到——但两侧的机制不同。**CC 侧**:开口
- > 时会被提示有未读;武装 watcher 后,消息一到就把会话叫醒(实测约 8 秒)。**Codex 侧**:
- > 开口时同样会被提示(CLI 与桌面端都已实测);要在没人开口时收到,得由它自己调
- > `channel_wait` 挂起等,来信即返回——没在等的时候不会被叫醒。会话完全退出后两侧都
- > 收不到,那时模型并不在运行。
-
- 不要把 CC 侧的"8 秒"套到 Codex 上说成"两个 AI 实时互通"。CC 的 8 秒是**检查频率**不是
- 端到端延迟;Codex 的 `channel_wait` 是**显式挂起**,收到快慢取决于它有没有在等、等多久。
- 别拿这些数去推一个确定的延迟承诺。