概述
Agent Runtime Bridge 是本机桥接层:它启动并监控 Agent Host,接管双向标准流,把结构化阻塞请求投影成 WAITING_INPUT 或 WAITING_APPROVAL,再用原来的请求关联键把用户决策写回同一调用。
TUI、IDE 或页面都可以消费 Bridge 暴露的 Waiting 事件;真正接管 Agent 标准流、保存关联键并恢复原请求的,是 Bridge 内部的进程编排和 Protocol Adapter。
最短结论:
- Waiting 与 Approval 不是页面状态,而是对 Agent Host 结构化阻塞请求的投影。
stdout/stdin只提供本机默认传输;定义“这是一个问题、这是一次审批、响应对应哪一个请求”的,是 JSON-RPC 或 control protocol。- 用户已回答、Adapter 已写入 stdin、Agent 已恢复,是三件必须分开记录的事实。
协议字段核对于 2026-08-24。Codex 以官方 app-server 文档和本机
codex-cli 0.149.0-alpha.4Schema 为准;Claude Code 以本机2.1.205 --help为准。
Agent Runtime Bridge 是什么
把一次需要确认的 Agent 执行放到本机编排环境里,问题立刻从“终端阻塞”变成“跨进程控制”:
| 控制端看到的动作 | Bridge 必须完成的事情 |
|---|---|
| 展示问题或审批卡 | 识别 Agent 发出的结构化阻塞请求 |
| 用户提交答案或允许/拒绝 | 找到仍然挂起的原始请求,而不是新建一条 Prompt |
| 显示“已处理” | 用原关联键把决策写回 Agent 的输入通道 |
| 这次执行继续 | 收到后续事件,确认请求关闭,再重算运行状态 |
只转发 stdout 文本,控制端也许能看见问题,却无法恢复原来的调用。只发送一条新 Prompt,原来的请求仍可能停在等待处。Bridge 存在的理由,就是把这两半接上。
本文把参与者固定为三层:
| 参与者 | 负责什么 | 不负责什么 |
|---|---|---|
| 控制端 | 展示 Waiting,提交 answer 或 decision | 不直接读写 Agent 标准流,不理解厂商协议字段 |
| Agent Runtime Bridge | 编排进程;用 Adapter 解析事件、保存关联键、按原协议回包;投影 Waiting 状态 | 不读取模型隐藏推理,不把自然语言问句当成可恢复请求 |
| Agent Host | 执行一次 Session/Turn,发出结构化事件和阻塞请求 | 不理解控制端的 Waiting 枚举 |
图 1:Bridge 位于控制端和 Agent Host 之间。只有 Adapter 与 CLI 这一段直接接管 stdout / stdin。控制端看到的是统一 Waiting 事件,不是 Codex 或 Claude 的原生消息。
Hook 与 Bridge 不在同一层。Hook 适合在
PreToolUse、PostToolUse或Stop做策略、审计和升级审批;它通常不长期保存跨进程关联键,也不负责数分钟后把响应写回原来的 stdin。类似开源项目的 Claude 路径里,Hook 返回ask后,控制请求才真正暂停并等待回包。Hook 决定“这次要问”,Bridge 决定“如何暂停、关联和恢复”。
双向 STDIO 只解决传输
Bridge 把 CLI Agent 作为子进程启动,并把标准流接到 Adapter。这里的启动就是 spawn:当前进程再拉起另一个程序,并把它当成自己的子进程。它是操作系统里的进程创建,不是 JSON-RPC 字段,也不是 Codex 专用词。Node 里是 child_process.spawn,Python 里是 subprocess.Popen;终端里敲一条命令,shell 做的是同一件事。
Bridge(父进程)
├─ spawn
└─ Agent Host / CLI(子进程)
stdin :Adapter → Agent
stdout :Agent → Adapter
stderr :Agent → Adapterspawn 之后,父进程可以接管子进程的 stdin / stdout / stderr,并在它退出时拿到退出码。不 spawn、不接管这三根管子,父进程就读不到 Server Request,也写不回审批。自己另开一个官方窗口跑 Codex,那条管道在官方 UI 手里,不属于 Bridge。
| 通道 | 方向 | 主要内容 | 是否作为状态依据 |
|---|---|---|---|
stdout | Agent → Adapter | JSON/JSONL 事件、服务端请求、执行结果 | 是,但只消费已识别的结构化消息 |
stdin | Adapter → Agent | 新输入、请求响应、控制命令 | 是,用于继续被阻塞的执行 |
stderr | Agent → Adapter | 日志、警告、崩溃诊断 | 否,默认只作诊断证据 |
| 进程信号或控制方法 | Adapter → Agent | interrupt、cancel、kill | 是,用于取消和异常收敛 |
“监听 stdout”只描述了半条链路。支持 Waiting 的必要条件是:Adapter 收到一个带厂商关联键的阻塞请求,并能在之后用同一个键回包。如果 CLI 只输出一段文本后退出,即使控制端能展示问题,也无法恢复原来的调用现场。
stdio 不是唯一实现。Unix Socket、WebSocket 或 SDK 回调也可以承载同一协议。它适合本机桥接,是因为父进程天然拥有子进程生命周期和输入输出,不必额外开放端口。Waiting 机制不应绑死某一种传输。
本文不展开字节分帧、鉴权或机器注册。关注点只有三件事:请求关联、人工决策如何回到原通道、Agent 如何从 Waiting 恢复。
Waiting 与 Approval 如何往返
沿用“Agent 执行到一半需要用户回答”的场景。控制端永远不需要理解 Agent 的原生消息格式。
图 2:关联键留在 Adapter 内。用户已回答、stdin 已写入、Agent 已恢复,是三个不同事实。
| 步骤 | Bridge 动作 | 运行状态 |
|---|---|---|
| 1 | 启动 Agent,Adapter 建立协议连接 | RUNNING |
| 2 | Agent 发出带厂商关联键的阻塞型 Question 或 Approval | RUNNING |
| 3 | Adapter 解析请求,Bridge 投影 Waiting | WAITING_INPUT 或 WAITING_APPROVAL |
| 4 | 控制端提交 answer 或 decision | 仍保持 Waiting |
| 5 | Adapter 按原关联键写入 stdin | 仍保持 Waiting |
| 6 | Agent 发出请求关闭、后续进度、新的阻塞请求或终止事件 | RUNNING、继续 Waiting 或终态 |
Question 和 Approval 走同一条链路,差别只在请求类型、回包结构和 Waiting 投影:
| 统一事件 | Waiting 投影 | 回包 |
|---|---|---|
| Question | WAITING_INPUT | answers,通常包含一个或多个结构化选项 |
| Approval | WAITING_APPROVAL | allow / deny / cancel,或会话级授权 |
把 decision 交给运行时,并不预先决定后继状态。运行时可能继续、再次等待、失败或取消。同步完成的自动审批只写审计记录,不必让运行短暂进入 WAITING_APPROVAL。
回复当前协议请求仍属于原执行;主动追加一条新消息才开始新的执行。无论控制端如何切分执行单元,响应都必须打回原来的厂商请求,而不是另开一条 Prompt。
Bridge 需要同时记住三类状态:
| 事实 | 含义 | 还不能证明什么 |
|---|---|---|
| 用户决策已产生 | 控制端已经给出 answer 或 decision | Agent 已看到响应 |
| 响应已写入通道 | Adapter 成功写入 stdin 或等价通道 | Agent 已消费并恢复 |
| 运行时请求已关闭 | Agent 发出 resolved/cleared,或后续生命周期事件表明 pending 已消失 | 这次回答一定成功 |
用户已决策、通道已写入、请求仍 OPEN,是合法中间态:控制端已经回答,stdin 已经写完,Agent 仍可能停在原请求上。因此 Waiting 不能在“提交了允许”或“stdin 写成功”时直接改成 RUNNING。
最小实现最好保证同一时刻至多一个仍为 OPEN 或 UNKNOWN 的阻塞请求。Question 与 Approval 并发时,单值的 WAITING_INPUT / WAITING_APPROVAL 无法同时表达原因。
Adapter 把厂商协议留在边界内
控制端和 Waiting 枚举不应依赖某个 Agent 的消息名称。每个 Adapter 负责厂商协议和统一事件之间的转换。
Bridge 内部 Adapter 的接口示意:Bridge 只通过这组方法驱动一次运行,具体 Agent 的 stdin / stdout 字段留在 Adapter 里。
// 阻塞请求的原关联键。回包时必须原样写回,不能先收成字符串再猜。
// method 说明这是哪一类请求;id 只标识“这一次”请求,
// 同类 method 再发一次会换新的 id,不是工具或方法的固定编号。
type AgentRequestKey = {
method: string; // 请求种类,例如 item/tool/requestUserInput
id: string | number; // 这一次请求的配对号;JSON-RPC 的 id,或 Claude 的 request_id
};
interface AgentAdapter {
// 启动前声明这个 Agent 实际具备哪些能力,供 Bridge 决定能否做 Waiting。
capabilities(): AgentCapabilities;
// 拉起一次新的 Agent 运行:spawn 子进程,把 stdout/stdin 接到本 Adapter。
startRun(input: StartRunInput): Promise<RunHandle>;
// 接到已有 Session,而不是另开一条 Prompt。
resumeSession(input: ResumeSessionInput): Promise<RunHandle>;
// 取消这次运行。Adapter 负责发 interrupt/cancel,或在必要时杀掉子进程。
cancelRun(runId: string): Promise<void>;
// 把已识别的统一事件交给 Bridge:进度、Question、Approval、终止等。
// 控制端不直接订阅 stdout。
onEvent(handler: (event: AgentEvent) => void): void;
// 用户决策回来后,按原关联键写入 stdin(或等价通道)。
// 只表示“通道写成功”,不表示 Agent 已经恢复。
deliverResponse(
externalRequestKey: AgentRequestKey,
response: WaitingResponse
): Promise<void>;
}能力由 Adapter 明确声明:
interface AgentCapabilities {
structuredEvents: boolean; // stdout 是否输出可解析的结构化事件
bidirectionalControl: boolean; // 是否能按原关联键把响应写回
interactiveQuestions: boolean; // 是否支持结构化提问并原地恢复
humanApproval: boolean; // 是否支持命令/文件/权限审批
sessionResume: boolean; // 是否能接到已有 Session
interrupt: boolean; // 是否支持中断当前 Turn
sandbox: boolean; // 是否有受限执行环境
}接入时按能力对 Agent 分级:这里一半都是 AB 级别了,C、D 级别的 Agent 要么小众,要么不主流。
| 等级 | Agent 能力 | Bridge 策略 |
|---|---|---|
| A | 原生双向结构化协议 | 完整实现 Waiting、Approval 和原地恢复 |
| B | 官方 SDK 提供回调 | 用 SDK 包装成统一 Adapter |
| C | 只有结构化事件,但支持 Hook 或 MCP | 用 Hook/MCP 补充部分提问能力,并声明降级边界 |
| D | 只有交互式终端文本 | 不进入完整支持列表;PTY 解析只作临时兼容 |
对于 C、D 级 Agent,不能伪造 interactiveQuestions: true。如果无法恢复同一次调用,应结束当前执行,回答后再恢复 Session;这是明确降级,不是原地继续。
Codex app-server:可验证的双向实现
对需要双向控制的场景,接入 Codex app-server,而不是解析交互式终端:
codex app-server --stdio默认是 STDIO 上的 JSONL:一行一个 JSON 对象。客户端 Request 的 id 由 Adapter 生成;提问和审批是 Server Request,id 由 app-server 生成,回包必须原样带回。
服务端请求与客户端响应
管道上只有三类对象。
带 id 的客户端请求,例如开一轮:
{
"id": 3,
"method": "turn/start",
"params": {
"threadId": "THREAD_ID",
"input": [{ "type": "text", "text": "只回答:pong" }]
}
}对应响应用同一个 id。turn/start 的 result 只表示这一轮被接受,通常是 inProgress;模型正文不在这里。
{
"id": 3,
"result": {
"turn": {
"id": "TURN_ID",
"status": "inProgress"
}
}
}没有 id 的是通知,不必回,例如进度和 item/agentMessage/delta。
同时有 id 和 method、没有 result 的,才是 Server Request。
Codex 也会一边跑一边往外倒事件,和 Claude 一样。差别在信封,不在“有没有流式输出”。判断这一行要不要回,不看 type,看 JSON-RPC 字段:
| 这一行 | 是什么 | 要不要回 |
|---|---|---|
有 id + method,没有 result / error | Request。可能是你发给 app-server 的,也可能是它发给你的提问 / 审批 | 必须用同一个 id 回 |
只有 method,没有 id | Notification | 不要回 |
有 id + result / error | 对你之前某次调用的 Response | 这是回包,不是新请求 |
type 是 Claude stream-json 的分类字段。Codex 行里出现的 type 只出现在 params 里(例如用户输入 {"type":"text"}),不是“要不要 request”的开关。
Notification 的 method 名也是官方协议的一部分,不是接入方私下再约定一套。JSON-RPC 2.0 只规定“没有 id 就是通知、不得回复”;具体有哪些通知,写在 Codex app-server 的 ServerNotification Schema 和 App Server 文档 里,例如 item/agentMessage/delta、turn/completed、serverRequest/resolved。换版本后同样重新导出 Schema,不要自己发明通知名,也不要把没有 id 的行当成必须回的 Request。
这些
method名是当前安装的 Codex 导出的协议。看本机有哪些方法:
codex app-server generate-json-schema --experimental --out ./schemas读 ServerRequest.json 看提问和审批;读 ServerNotification.json 看进度、delta、serverRequest/resolved 这类不必回的通知。oneOf 里每项的 method.enum 就是合法方法名。换 Codex 版本后应重新导出,不要把字符串写死。本机这份 Schema 里提问和审批是:
| 方法 | 用途 |
|---|---|
item/tool/requestUserInput | 结构化提问,目前 experimental |
item/commandExecution/requestApproval | 批准命令 |
item/fileChange/requestApproval | 批准改文件 |
item/permissions/requestApproval | 批准额外权限 |
审批从 stdout 读到的形状:
{
"id": 42,
"method": "item/commandExecution/requestApproval",
"params": {
"threadId": "THREAD_ID",
"turnId": "TURN_ID",
"itemId": "ITEM_ID"
}
}Adapter 用同一个 id 写回:
{
"id": 42,
"result": {
"decision": "accept"
}
}decision 还可以是 decline、cancel 或 acceptForSession。提问则回 answers,键名来自该请求 params.questions 里的 question id。42 和 THREAD_ID 都是示意,以这一次 stdout 为准。
serverRequest/resolved 的语义
OpenAI 官方文档说明:客户端响应 item/tool/requestUserInput 后,app-server 会发送 serverRequest/resolved,其中包含 { threadId, requestId }。命令审批也用同一事件确认 pending request 已经回答或被清理。
这个事件不是“执行成功”事件。请求若在客户端回答前被 turn start、turn completion 或 turn interruption 清理,也会收到相同通知。因此它可以把运行时请求标为已关闭,却不能单独证明这次回答成功,或运行已恢复。
会话级的 acceptForSession 还会影响后续相似请求是否再次进入 Waiting,应作为审计事件保存,不能只记录一个布尔值。
Transport 与版本边界
app-server 默认使用 STDIO JSONL,也可以使用 Unix Socket 或 WebSocket;对本机进程编排而言,--stdio 最简单。当前 OpenAI 官方文档仍将 app-server 命令与 WebSocket transport 标记为 experimental,并注明不支持生产负载。
接入时必须做协议版本和能力探测,尤其不能把 experimental 的 requestUserInput 字段永久写死在业务层。没有等价确认事件的 Agent,只能在收到关联的后续进度或终止事件时关闭请求;连接状态不明时应记录 UNKNOWN。
Claude Code:stream-json 与 control protocol
Claude 同样一边跑一边往 stdout 倒 JSON 行。分类字段是 type,由 Claude 的事件信封规定,接入方按这个字段解析。这不是临时口头约定“哪些内容算控制请求”,而是协议里已经写好:type 决定这一行是普通事件,还是必须回答的控制请求。
多数行不必回:
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}需要批准工具或向用户提问时,同一条流里再出现 control request:对方必须回答的控制请求。最小形状:
{"type":"control_request","request_id":"abc","request":{"subtype":"can_use_tool"}}接入方必须用同一个 request_id 回:
{"type":"control_response","response":{"subtype":"success","request_id":"abc"}}subtype 才说明这次要你干什么。实现里常见 can_use_tool:普通工具走 Approval;工具名是 AskUserQuestion 时走 Question。这和 Codex 的 item/commandExecution/requestApproval、item/tool/requestUserInput 是同一类事,只是 Claude 不用 JSON-RPC 的 method + id。
stream-json 只负责一行一个 JSON。真正接近 RPC 的是这条 control protocol,字段名是 Anthropic 的,不是 JSON-RPC 2.0。control_request 能出现哪些 subtype,以当前 Claude / Agent SDK 为准;开源项目用过,不等于字段已经冻结。
本机 Claude Code 2.1.205 的 CLI 帮助确认了以下参数入口;仅看到这些参数还不能证明完整的等待闭环:
--input-format=stream-json:通过标准输入持续接收结构化消息;--output-format=stream-json:通过标准输出持续发送结构化事件;--include-hook-events:把 Hook 生命周期事件加入输出流;--permission-mode:选择人工、自动、计划或 bypass 等权限策略。
类似开源项目的实现也进一步展示了双向控制链路。它以 -p 和双向 stream-json 启动 Claude Code,初始化 control protocol,并处理 control_request 中的 can_use_tool。普通工具进入 Approval;工具名为 AskUserQuestion 时则创建 Question。用户回答后,Adapter 必须按该 control_request 的 request_id 回传 control_response:Vibe 返回 Allow,并把答案写入 updated_input.answers;拒绝或超时则返回 Deny。
这证明该路径在实际项目中可行,但要区分两种证据:
| 判断 | 当前证据等级 |
|---|---|
| Claude Code CLI 声明 stream-json 输入和输出参数 | 已由本机 CLI 帮助直接验证 |
| Vibe 使用双向 stream-json 和 control request 接入审批、提问 | 已由固定提交源码直接验证 |
--permission-prompt-tool=stdio 是长期稳定的公开参数 | 待官方文档确认 |
control_request/control_response 的兼容承诺 | 待官方文档确认 |
因此,正式接入前还应核对 Anthropic 官方 Claude Agent SDK 或 Claude Code 文档,并固定最低版本、Schema 测试和降级策略。不能仅因为一个开源项目已经实现,就把其中所有字段视为官方稳定契约。
图 3:Codex 与 Claude Code 暴露的原生请求不同,但 Adapter 可以把它们归一化成 Question 或 Approval,再投影为 WAITING_INPUT 与 WAITING_APPROVAL。响应交给运行时以后,状态仍由后续事件和剩余 blocker 重算。
事件流不等于等待闭环
两个项目正好展示了“接上事件流”和“完成干预闭环”的区别。
| 项目 | 当前实现 | 当前闭环能力 |
|---|---|---|
| Dashi Taskboard | 主流程把任务打开到 Codex 原生 Composer;内置 AI Chat 使用 codex exec --json 或 app-server | 前者把干预留在 Codex 原生 UI,不属于 Bridge 闭环;内置面板尚未完成请求回包 |
| Vibe Kanban | Codex Adapter 处理 app-server ServerRequest;Claude Adapter 处理 control request;后端维护 approval waiter | 已实现问题和审批的回包,但 Pending Approval 主要保存在内存,进程重启后恢复仍可加强 |
截至 Dashi 提交 87df11e24d7ad07a7db9b407c83c23b6a4b852cb,它的 app-server 包装层会对所有带 id + method 的服务端请求返回 Unsupported server request,前端只归一化 running / completed / failed。Dashi 并非统一开启 dangerous mode:普通执行可以使用 on-request + auto_review,danger-full-access + never 是每轮需要显式确认的可选模式。
Danger 或 bypass 只能减少权限审批,不能消除需要人回答的问题。Agent 仍可能要确认兼容策略、交互方案或验收口径,因此不能通过开启高权限来删除 WAITING_INPUT。
Bridge 可靠性边界
能弹出一个审批框,不代表已经形成可靠桥接。最小实现至少需要满足:
- 结构化协议:不把自然语言、终端光标或 ANSI 输出当成状态事实。
- 关联键分层:控制端使用内部 Waiting ID;Adapter 使用保留原类型的厂商关联键和原方法名。重复事件不创建第二个待办。
- 三类事实分开记录:用户决策、通道写入、运行时请求关闭不能互相替代。
- 写入成功后不盲改状态:stdin 写完仍保持 Waiting,直到 Agent 后续事件到来。
- 确认丢失时标记 UNKNOWN:不要对非幂等响应盲目重写;优先用 Codex 的
serverRequest/resolved或其他 Agent 的关联后续事件对账。 - 进程断联必须收敛:Adapter 或 Agent 退出时,不能让运行永久停在 Waiting。最小实现可以把本轮标为失败,再从 Session 新建执行。
- 能力协商:启动前记录 Agent 版本和能力;未知方法进入兼容错误,不静默丢弃。
- 安全默认值:本机默认使用受限 Sandbox 和按需审批;danger/bypass 只用于明确隔离且逐次确认的环境。
最小验证顺序是先跑通 Codex app-server 的双向 STDIO,再以同一 Adapter 接口接入 Claude Code。这是验证关联键和状态投影的工程顺序,不是把 experimental app-server 当成无条件的生产契约。正式接入仍需要版本固定、能力探测和明确降级路径。最小实现明确不做通用 PTY 文本解析,也不要求所有 Agent 一开始就具备同等能力。
资料来源与证据边界
- 协议和源码核对时间:2026-08-24。本机
codex app-server --stdio最小往返核对于 2026-09-07(codex-cli 0.153.4)。 - OpenAI 官方文档:Codex App Server。用于确认双向 JSON-RPC、默认 STDIO JSONL、Server Request、
serverRequest/resolved与 transport 的实验性边界。 - 本机
codex app-server generate-json-schema --experimental生成的ServerRequest.json与ServerNotification.json(核对过codex-cli 0.149.0-alpha.4与0.153.4)。通知的method以 Schema 为准,不是接入方私约。 - 本机
Claude Code 2.1.205 --help,用于确认公开的 stream-json、Hook events 和 permission mode 参数。 - Dashi:打开 Codex 原生 Composer 的主流程说明。
- Dashi:Codex 启动权限参数。
- Dashi:尚未支持 app-server 服务端请求。
- Dashi:AI Chat 前端状态归一化。
- Vibe Kanban:Claude Code CLI 与 stream-json 启动参数。
- Vibe Kanban:Claude Approval 与 AskUserQuestion 处理。
- Vibe Kanban:Codex ServerRequest 处理。
- Vibe Kanban:Approval waiter 与回答 API。
评论