概述

Agent Runtime Bridge 是本机桥接层:它启动并监控 Agent Host,接管双向标准流,把结构化阻塞请求投影成 WAITING_INPUTWAITING_APPROVAL,再用原来的请求关联键把用户决策写回同一调用。

TUI、IDE 或页面都可以消费 Bridge 暴露的 Waiting 事件;真正接管 Agent 标准流、保存关联键并恢复原请求的,是 Bridge 内部的进程编排和 Protocol Adapter。

最短结论:

  1. Waiting 与 Approval 不是页面状态,而是对 Agent Host 结构化阻塞请求的投影。
  2. stdout / stdin 只提供本机默认传输;定义“这是一个问题、这是一次审批、响应对应哪一个请求”的,是 JSON-RPC 或 control protocol。
  3. 用户已回答、Adapter 已写入 stdin、Agent 已恢复,是三件必须分开记录的事实。

协议字段核对于 2026-08-24。Codex 以官方 app-server 文档和本机 codex-cli 0.149.0-alpha.4 Schema 为准;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 枚举
Agent Runtime Bridge 是本机桥接层

图 1:Bridge 位于控制端和 Agent Host 之间。只有 Adapter 与 CLI 这一段直接接管 stdout / stdin。控制端看到的是统一 Waiting 事件,不是 Codex 或 Claude 的原生消息。

Hook 与 Bridge 不在同一层。Hook 适合在 PreToolUsePostToolUseStop 做策略、审计和升级审批;它通常不长期保存跨进程关联键,也不负责数分钟后把响应写回原来的 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 → Adapter

spawn 之后,父进程可以接管子进程的 stdin / stdout / stderr,并在它退出时拿到退出码。不 spawn、不接管这三根管子,父进程就读不到 Server Request,也写不回审批。自己另开一个官方窗口跑 Codex,那条管道在官方 UI 手里,不属于 Bridge。

通道方向主要内容是否作为状态依据
stdoutAgent → AdapterJSON/JSONL 事件、服务端请求、执行结果是,但只消费已识别的结构化消息
stdinAdapter → Agent新输入、请求响应、控制命令是,用于继续被阻塞的执行
stderrAgent → Adapter日志、警告、崩溃诊断否,默认只作诊断证据
进程信号或控制方法Adapter → Agentinterrupt、cancel、kill是,用于取消和异常收敛

“监听 stdout”只描述了半条链路。支持 Waiting 的必要条件是:Adapter 收到一个带厂商关联键的阻塞请求,并能在之后用同一个键回包。如果 CLI 只输出一段文本后退出,即使控制端能展示问题,也无法恢复原来的调用现场。

stdio 不是唯一实现。Unix Socket、WebSocket 或 SDK 回调也可以承载同一协议。它适合本机桥接,是因为父进程天然拥有子进程生命周期和输入输出,不必额外开放端口。Waiting 机制不应绑死某一种传输。

本文不展开字节分帧、鉴权或机器注册。关注点只有三件事:请求关联、人工决策如何回到原通道、Agent 如何从 Waiting 恢复。

Waiting 与 Approval 如何往返

沿用“Agent 执行到一半需要用户回答”的场景。控制端永远不需要理解 Agent 的原生消息格式。

一次 Waiting 请求如何经 Bridge 暂停并恢复

图 2:关联键留在 Adapter 内。用户已回答、stdin 已写入、Agent 已恢复,是三个不同事实。

步骤Bridge 动作运行状态
1启动 Agent,Adapter 建立协议连接RUNNING
2Agent 发出带厂商关联键的阻塞型 Question 或 ApprovalRUNNING
3Adapter 解析请求,Bridge 投影 WaitingWAITING_INPUTWAITING_APPROVAL
4控制端提交 answer 或 decision仍保持 Waiting
5Adapter 按原关联键写入 stdin仍保持 Waiting
6Agent 发出请求关闭、后续进度、新的阻塞请求或终止事件RUNNING、继续 Waiting 或终态

Question 和 Approval 走同一条链路,差别只在请求类型、回包结构和 Waiting 投影:

统一事件Waiting 投影回包
QuestionWAITING_INPUTanswers,通常包含一个或多个结构化选项
ApprovalWAITING_APPROVALallow / deny / cancel,或会话级授权

把 decision 交给运行时,并不预先决定后继状态。运行时可能继续、再次等待、失败或取消。同步完成的自动审批只写审计记录,不必让运行短暂进入 WAITING_APPROVAL

回复当前协议请求仍属于原执行;主动追加一条新消息才开始新的执行。无论控制端如何切分执行单元,响应都必须打回原来的厂商请求,而不是另开一条 Prompt。

Bridge 需要同时记住三类状态:

事实含义还不能证明什么
用户决策已产生控制端已经给出 answer 或 decisionAgent 已看到响应
响应已写入通道Adapter 成功写入 stdin 或等价通道Agent 已消费并恢复
运行时请求已关闭Agent 发出 resolved/cleared,或后续生命周期事件表明 pending 已消失这次回答一定成功

用户已决策、通道已写入、请求仍 OPEN,是合法中间态:控制端已经回答,stdin 已经写完,Agent 仍可能停在原请求上。因此 Waiting 不能在“提交了允许”或“stdin 写成功”时直接改成 RUNNING

最小实现最好保证同一时刻至多一个仍为 OPENUNKNOWN 的阻塞请求。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 Requestid 由 app-server 生成,回包必须原样带回。

服务端请求与客户端响应

管道上只有三类对象。

id 的客户端请求,例如开一轮:

{
  "id": 3,
  "method": "turn/start",
  "params": {
    "threadId": "THREAD_ID",
    "input": [{ "type": "text", "text": "只回答:pong" }]
  }
}

对应响应用同一个 idturn/startresult 只表示这一轮被接受,通常是 inProgress;模型正文不在这里。

{
  "id": 3,
  "result": {
    "turn": {
      "id": "TURN_ID",
      "status": "inProgress"
    }
  }
}

没有 id 的是通知,不必回,例如进度和 item/agentMessage/delta

同时有 idmethod、没有 result 的,才是 Server Request。

Codex 也会一边跑一边往外倒事件,和 Claude 一样。差别在信封,不在“有没有流式输出”。判断这一行要不要回,不看 type,看 JSON-RPC 字段:

这一行是什么要不要回
id + method,没有 result / errorRequest。可能是你发给 app-server 的,也可能是它发给你的提问 / 审批必须用同一个 id
只有 method,没有 idNotification不要回
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/deltaturn/completedserverRequest/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 还可以是 declinecancelacceptForSession。提问则回 answers,键名来自该请求 params.questions 里的 question id。42THREAD_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/requestApprovalitem/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_requestrequest_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 测试和降级策略。不能仅因为一个开源项目已经实现,就把其中所有字段视为官方稳定契约。

厂商协议如何经 Adapter 投影为 Waiting 状态

图 3:Codex 与 Claude Code 暴露的原生请求不同,但 Adapter 可以把它们归一化成 Question 或 Approval,再投影为 WAITING_INPUTWAITING_APPROVAL。响应交给运行时以后,状态仍由后续事件和剩余 blocker 重算。

事件流不等于等待闭环

两个项目正好展示了“接上事件流”和“完成干预闭环”的区别。

项目当前实现当前闭环能力
Dashi Taskboard主流程把任务打开到 Codex 原生 Composer;内置 AI Chat 使用 codex exec --json 或 app-server前者把干预留在 Codex 原生 UI,不属于 Bridge 闭环;内置面板尚未完成请求回包
Vibe KanbanCodex 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_reviewdanger-full-access + never 是每轮需要显式确认的可选模式。

Danger 或 bypass 只能减少权限审批,不能消除需要人回答的问题。Agent 仍可能要确认兼容策略、交互方案或验收口径,因此不能通过开启高权限来删除 WAITING_INPUT

Bridge 可靠性边界

能弹出一个审批框,不代表已经形成可靠桥接。最小实现至少需要满足:

  1. 结构化协议:不把自然语言、终端光标或 ANSI 输出当成状态事实。
  2. 关联键分层:控制端使用内部 Waiting ID;Adapter 使用保留原类型的厂商关联键和原方法名。重复事件不创建第二个待办。
  3. 三类事实分开记录:用户决策、通道写入、运行时请求关闭不能互相替代。
  4. 写入成功后不盲改状态:stdin 写完仍保持 Waiting,直到 Agent 后续事件到来。
  5. 确认丢失时标记 UNKNOWN:不要对非幂等响应盲目重写;优先用 Codex 的 serverRequest/resolved 或其他 Agent 的关联后续事件对账。
  6. 进程断联必须收敛:Adapter 或 Agent 退出时,不能让运行永久停在 Waiting。最小实现可以把本轮标为失败,再从 Session 新建执行。
  7. 能力协商:启动前记录 Agent 版本和能力;未知方法进入兼容错误,不静默丢弃。
  8. 安全默认值:本机默认使用受限 Sandbox 和按需审批;danger/bypass 只用于明确隔离且逐次确认的环境。

最小验证顺序是先跑通 Codex app-server 的双向 STDIO,再以同一 Adapter 接口接入 Claude Code。这是验证关联键和状态投影的工程顺序,不是把 experimental app-server 当成无条件的生产契约。正式接入仍需要版本固定、能力探测和明确降级路径。最小实现明确不做通用 PTY 文本解析,也不要求所有 Agent 一开始就具备同等能力。

资料来源与证据边界


相关笔记