01 · Three layers
进程退出、界面更新和模型醒来是三件事
我重新读这几套实现时,先把“唤醒”这个词拆开了。后台进程可以自己感知结束,TUI 可以立即更新命令卡片,主会话也可以再发一次模型请求。它们常常挨着发生,代码里却由不同对象负责。
进程层
watcher 等退出信号,收尾 stdout,保存 exit code。这一步不需要模型。
界面层
终端或 App Server 收到事件,把 running 改成 completed。用户已经能看见结果。
会话层
运行时构造一条模型可见输入,再调一次 API。Agent 才能继续验产物、改文件或汇报。
这层区分改掉了我最初一个过于粗的说法。Codex 并不缺进程退出事件。它有完整的异步 watcher,也会推送输出增量和完成状态。它缺少的是从普通后台 terminal 的完成事件直达新模型回合的默认路径。
判断监控体验时,先问事件最后交给了谁。
交给 TUI,用户少按一次刷新。交给模型输入队列,Agent 才能在用户离开以后继续做下一步。
本次检查的三个安装包
Grok 指向 ~/.grok/downloads/grok-1.0.5-macos-aarch64,文件大小 134,349,648 字节,SHA-256 为 3dfa7f04fbb5427a8fbead286591543aaecb478b3a0ab222c4329eca1a3b2f86。签名方是 X.AI Corporation。
Claude Code 位于 ~/.local/share/claude/versions/2.1.241,文件大小 325,055,632 字节,SHA-256 为 1495eb7c42d3b4451f5f1cd38b6d498d22a4a38c802bc2be5c1cf1795e64820d。签名方是 Anthropic PBC。
Codex 原生程序来自 @openai/codex 0.149.1 的 darwin-arm64 包,文件大小 220,552,944 字节,SHA-256 为 f0d8762236594359b60cfbe17f4c7e945a3ce8d1c91e74778838c968d250fb6c。签名方是 OpenAI OpCo, LLC。源码检查固定在 rust-v0.149.1 对应的提交 ff29a44391deccde0aba0f8390337d7f3c319ea4。
02 · Grok Build
Grok 把 TaskCompleted 改造成一条合成 Prompt
Grok 的公开源码把这条路写得很直。后台 Bash 或 Monitor 自然退出后,shell adapter 产生 ToolNotification::TaskCompleted。notification_bridge.rs 先检查几件事。任务有没有被 block wait 接走,是否由模型明确停止,goal loop 是否还在运行,AutoWake 有没有开启。
通过这些检查以后,bridge 生成 task-completed-{task_id},再向 session actor 发送 SessionCommand::Prompt。Prompt 的模式是 Agent,内容提醒模型 Monitor 已经结束,并告诉它从哪个任务读取完整输出。
TaskCompleted后台任务自然退出,快照带着任务类型和退出码进入 bridgeSessionCommand::Promptbridge 构造一条合成 Prompt,并附上 admission 通道admit_task_completion_wakesession actor 再检查取消后的抑制状态和通知状态queue_input通过 admission 的 Prompt 进入 pending inputshandle_prompt运行时开启合成回合,模型继续处理产物admission 这一层很要紧。用户刚取消过任务时,新的完成事件可能与取消操作撞在一起。session actor 用 task_wake_suppressed 和 notifications_suppressed 把这类事件挡住。接收失败时,完成消息会退回延后通知,不会悄悄丢掉。
Monitor 中途输出和自然退出走两条路
Monitor 打出一行有意义的 stdout 时,bridge 收到 MonitorEvent,以 NotificationPriority::Next 放进通知队列。Monitor 自然退出时,TaskCompleted 走上面的合成 Prompt 通道。完成事件已经预留任务以后,晚到的 MonitorEvent 会被丢掉,避免同一个终态开两个回合。
公开测试专门覆盖了这件事。退出码为零的 Monitor 会生成含有 [monitor ended: exited (code 0)] 的 Prompt,随后发送 DropMonitorNotifications。模型主动停止 Monitor 时不会再 AutoWake,UI 或 Stop 操作终止 Monitor 时则会提醒模型不要重新启动它。
auto-wake: requesting synthetic prompt admission
auto-wake: session actor received synthetic prompt
skipping model inject for monitor event: task already auto-woke
本机安装的 Grok 1.0.5 二进制里能找到上面三段日志文本,对应字节偏移分别落在 107441761、107737945 和 107442351 附近。公开仓库目前没有可解析到这个安装包的 1.0.5 标签,二进制内的构建标识 5115b46bc909 也无法从公开远端直接取回。因此我用安装包字符串确认能力存在,用公开代码解释结构,没有把两者写成同一个提交。
03 · Claude Code
Claude Code 的 next 队列确实会在空闲时开新回合
Claude Code 2.1.241 的 Monitor 实现在发布二进制的 Bun JavaScript 里。命令 stdout 先进入 PBi,由 HSl 切行、合并和限流。xwe 随后构造一条 task-notification,优先级设为 next,交给 enqueuePendingNotification。
mode: "task-notification",
priority: "next"
主查询循环在回合开始和工具边界读取 getCommandsByMaxPriority("next")。当前模型请求正在跑时,next 通知不会像 now 那样立即抢断。回合空闲时,队列可以直接生成下一次请求。
静态代码已经说明队列怎样工作,本机 GPU 项目的会话记录又给了两种真实时序。我只抽取时间戳、任务标识、队列动作和消息来源,没有把任务输出或聊天正文带进文章。
| Monitor | 运行结果 | 队列动作 | 模型侧表现 |
|---|---|---|---|
bhceuqzer | 25 分钟超时 | 入队后 7 毫秒出队,8 毫秒后写成 user 消息 | 约 51 秒后出现首条 assistant 记录 |
brct5dn0c | 11 分 31 秒成功 | 两条通知入队,4 毫秒后出队 | 10 毫秒后出现合成 user 消息,约 45 秒后模型输出 |
btbog7o0q | 8 分 11 秒成功 | 模型工作期间入队,24.8 秒后在工具边界移除 | 没有抢断当时正在进行的 Edit |
bu6hbevli | 40 分钟超时 | 事件先入队,稍后随当前工作收尾 | 保持 next 的非抢断语义 |
前两条最能回答最初的问题。队列入队以后,Claude Code 没有等用户再敲一句话。它把通知写成新的 user 消息,随后发起模型请求。几十秒延迟主要发生在模型响应阶段,队列本身只用了几毫秒。
第三条说明了另一面。Monitor 在 03:53:30.677 产生完成事件,当时主会话还在分析图片和改文档。通知留在队列里。03:53:55.082,模型发出 Edit。工具结果回来以后,两条 Monitor 通知才从队列移除。事件驱动没有破坏当前回合的工具顺序。
priority: "next" 表示排到下一个可用边界。
空闲时,它能开启新回合。模型仍在工作时,它先排队。用“等 Monitor 结束才继续”概括 Claude Code,大体方向对了,还要补上这个非抢断条件。
通知在会话文件里是一条合成 user 消息
本机记录里的这几条消息都带着 origin.kind = "task-notification" 和 promptSource = "system",消息角色仍是 user,permissionMode 为 bypassPermissions。这能证明运行时在没有新的人类输入时调用了模型,不能单独证明通知回合可以执行任何动作。
Anthropic 的公开 issue 里已经有人提交过相同形态的记录。一个 issue 展示了后台通知在空闲时触发自主 API 请求,另一个 issue 记录了模型把通知误当成人类回答的情况。这些来源属于用户报告,不能当作 Claude Code 实现文档,却提醒了一个真实的设计问题。自动唤醒省掉人工等待以后,通知来源、权限和待回答问题都要跟着收紧。
04 · Codex
Codex 能把完成状态推到界面,模型仍要自己来取
Codex 0.149.1 的 UnifiedExec 启动后台进程时,会同时启动两个异步任务。start_streaming_output 持续读取 PTY,发送 ExecCommandOutputDelta。spawn_exit_watcher 等取消令牌,也就是进程退出信号,随后等输出排空,聚合 transcript,最后发送一条 ExecCommandEnd。
spawn_exit_watcher等待进程退出,排空剩余输出ExecCommandEnd带着退出码、运行时长和聚合输出发送事件ItemCompletedApp Server 把事件映射为命令卡片完成write_stdin模型另行发起空查询,拿到工具结果event_mapping.rs 把 ExecCommandEnd 映射为 ServerNotification::ItemCompleted。这条路径解释了 Codex 界面为什么能在后台命令结束时更新。它绑定的是原始 turn 和 command item,没有向 thread 插入新的用户输入,也没有提交新的 turn。
模型侧的工具合同在另一处。exec_command 超过初始等待时间以后返回 session ID。write_stdin 把空 chars 明确定义为 background poll。空查询至少等 5 秒,默认最多等 300 秒。进程在等待窗口内结束,这次工具调用会拿到 exit code 和剩余输出。
if request.input.is_empty() {
time_ms.clamp(
MIN_EMPTY_YIELD_TIME_MS,
self.max_write_stdin_yield_time_ms,
)
}
于是 Codex 同时拥有两种相反的体验。用户能及时看到命令卡片结束,模型若已经交还当前回合,并不会仅凭这张卡片重新运行。模型仍在等待回合里时,会继续调用 write_stdin。用户后来再输入一句,它也可以回头读取已经结束的 session。
Codex 0.149.1 是界面 push,模型 pull。
退出事件真实存在,UI 也能实时更新。模型续接仍靠工具查询。这两个判断放在一起,才和源码完全对得上。
05 · Session records
三条 Codex 会话留下了一百次空查询
源码能说明能力,历史会话能说明 Agent 真会怎么用。我筛了本机三条以 GPU 项目为 cwd 的 Codex 会话,只统计实际调用 tools.write_stdin 且 chars 为空的记录。总数正好是一百次。
这组记录没有统一控制命令、并发量和任务时长,其中也包含本文取证过程。它只能证明一个操作习惯。面对长任务,Codex 会反复发起空查询,长挂起把频率压低了,查询本身仍属于模型工具回合。
其中几条 terminal session 很能说明节奏。一个 session 在十分钟左右被空查 11 次,另一个也查了 11 次。每次工具等待约 55 或 60 秒,返回 still running 后,模型再安排下一次。任务没有更快,Agent 只是用更长的阻塞窗口少醒几次。
Claude Code 的四次 Monitor 没有对应的状态查询链。普通 shell 在任务外面安静地等条件,完成后才输出。主会话记录里留下的是 queue-operation 和合成通知。两边的本地进程都可能轮询,进入模型上下文的次数差得很明显。
这组统计怎样避免把字符串搜索算成工具调用
新版 Codex 会把工具编排保存在 custom_tool_call 的 JavaScript 输入里。我只解析实际出现的 tools.write_stdin({...}) 调用对象,再读取 chars 和 yield_time_ms。文章、源码搜索和提示词里出现的 write_stdin 文本没有计入。
九条调用使用未加引号的 JavaScript 对象键,无法直接交给 JSON 解析器。我另外按固定字段提取并人工核对。其中八条是 60 秒空查询,剩下一条发送中断字符。最终的一百次只保留空输入。
06 · Tradeoff
自动唤醒省模型回合,也增加了运行时的责任
把等待移到本地 watcher,收益很容易看见。没有状态变化时,模型不需要收一条“还在运行”。会话更短,工具调用更少,半小时任务尤其明显。
运行时接着要回答几个难问题。通知到达时模型正在做什么。用户刚按过取消键怎么办。两个终态挨着到达会不会开两次回合。通知内容来自日志,能不能被当成人类授权。Grok 的 admission、suppression 和 reservation 正是在处理这些竞争。Claude Code 的 next 优先级、队列折叠和任务来源标记处理了另一部分。
Codex 的模型 pull 显得笨一些,却天然保留了一个边界。当前 turn 结束以后,普通 terminal 完成只更新界面,不会自行调用模型。这会浪费等待回合,也减少了后台输出在无人看守时推动 Agent 行动的机会。
| 设计问题 | Grok Build | Claude Code | Codex 0.149.1 |
|---|---|---|---|
| 终态怎样到模型 | 合成 Prompt 进入 session actor | task-notification 进入 next 队列 | 模型调用 write_stdin 读取 |
| 当前回合忙碌时 | 进入输入队列,并受 admission 控制 | 等下一个工具或回合边界 | 当前工具回合按原计划继续 |
| 重复终态 | reservation 与 DropMonitorNotifications 去重 | 消息队列注册 in-flight 并移除已吸收项 | 进程条目退出后由 refresh state 移除 |
| 用户取消以后 | suppression gate 可以拒绝合成 wake | 依赖队列状态和任务生命周期处理 | 模型没有默认的终态 AutoWake |
| 主要成本 | 运行时竞争控制更复杂 | 合成 user 回合的来源和权限要可靠 | 长任务会产生重复模型查询 |
因此“有没有轮询”不够用来评估一款 Agent。更有用的问题是,轮询发生在普通进程还是模型回合,状态变化由谁接收,接收以后能不能安全地开启下一回合。
07 · Reading the result
长任务监控这一项,Grok 和 Claude Code 走得更完整
只看当前三个版本的普通长任务路径,我会把 Grok 放在最容易审计的位置。公开代码从 TaskCompleted 一直连到 session actor,取消、重复事件和 fallback 都有对应测试。Claude Code 的用户体验同样接近完整事件驱动,本机记录证明它在空闲时会自行开启模型回合。它的关键实现藏在发布二进制里,外部核验成本更高。
Codex 的源码透明度最高,进程层也已经有可靠 watcher。模型侧少了最后一段连接。把 ExecCommandEnd 转成一种有来源、受权限约束、可以被 thread admission 接受的输入,普通后台终端才会从“界面知道结束”走到“Agent 接着干活”。源码里曾出现的 awaiter 仍靠重复工具查询,0.149.1 默认角色又暂时移除了它,这条路还没有解决事件直达模型的问题。
那张 Grok 截图给人的感觉没有错。Monitor 在后台等,主 Agent 可以停。等条件成立以后,运行时再把它叫回来。好的监控还要知道什么时候保持安静,叫醒 Agent 时给它多大权限。
08 · Sources
源码、发布物与公开记录
- Grok Build 通知 bridgeTaskCompleted、MonitorEvent、AutoWake Prompt 和重复事件预留
- Grok Build session run loopTask wake admission 与合成 Prompt 入队
- Grok Build 通知 bridge 测试Monitor 自然退出、主动停止、取消与去重分支
- Claude Agent SDK 0.3.241 发布清单Claude Code 2.1.241 的平台二进制哈希和版本提交
- Claude Agent SDK Monitor 类型公开的 Monitor 输入与事件合同
- Claude Code 后台通知自主回合报告用户提交的 queue-operation、合成 user 消息和 API 请求记录
- Claude Code 通知回合角色混淆报告用户提交的 Monitor task-notification 行为记录
- Codex 0.149.1 异步退出 watcherPTY 输出流、退出等待和 ExecCommandEnd
- Codex App Server 事件映射ExecCommandEnd 到 ItemCompleted 的界面路径
- Codex 0.149.1 进程管理器后台 session、退出 watcher 和空查询等待范围
- Codex 0.149.1 write_stdin 处理器空输入作为 background poll 的模型工具入口