ACP:IDE 桥接
职责
serveAcpGateway(serveAcpGateway:42-173) 是 OpenClaw 给 IDE 用的 stdio 桥接进程:它是个独立 Node 程序(从 openclaw acp CLI 启动),用 stdin/stdout 跑 Agent Client Protocol 的 JSON-RPC,把 IDE 发来的 initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession 等方法翻译成 Gateway 的 WebSocket chat/run 调用,再把 Gateway 推过来的事件 (event) 翻译回 ACP 的 SessionUpdate 推给 IDE。
它的关键定位是"翻译器 (translator) + 中间人":自己不做 agent 推理、不直接跑工具、不存长期记忆,只把 ACP 协议和 Gateway 协议双向对齐。ACP 这套协议被 Zed 等编辑器采用,所以 OpenClaw 通过 serveAcpGateway 就能被这些 IDE 当作一个标准 agent server 接入,无需 IDE 知道 OpenClaw 的内部协议。
设计动机
为什么不直接让 IDE 连 gateway 的 WebSocket?
- stdio 友好:IDE 启动 agent server 的标准做法是 fork 一个子进程、用 stdin/stdout 通信。这避免 IDE 关闭时泄漏 TCP socket,也不要求 IDE 知道 gateway 的端口/token——只需要 IDE 知道
openclaw acp --gateway-url ... --token-file ...这条命令。 - 协议差异:ACP 用 JSON-RPC over ndjson,方法名是
initialize/prompt/cancel这些 agent 语义;Gateway 用 WebSocket 加自己的事件帧 (chat/agent/exec.approval.requested)。两边的事件粒度、字段命名、能力声明完全不同——AcpGatewayAgent(AcpGatewayAgent:250)就是专门做这个翻译的类。 - 断线重连语义:ACP 是请求-响应模型,IDE 发
prompt等结果;但 Gateway 是事件流,WebSocket 可能中途断开重连。AcpGatewayAgent维护pendingPromptsMap(pendingPrompts:258),把"在跑的 prompt"按sessionId索引,断线期间事件丢失了能在 reconnect 后reconcilePendingPrompts(handleGatewayReconnect:329-356)重新对齐状态。 - session 持久化与重放:IDE 重启后想看到上次的会话历史,ACP 协议有
loadSession/listSessions/resumeSession。AcpEventLedger(AcpEventLedger:49-73)把每个 prompt 和 SessionUpdate 落到 SQLite,gateway 重启后仍能 replay。
关键文件
serveAcpGateway:42-173— 入口:路由日志到 stderr、建 GatewayClient、等 hello、起AgentSideConnection。GatewayClient 选项:83-115—clientName: CLI、clientDisplayName: "ACP"、caps: [TOOL_EVENTS],onEvent/onHelloOk/onConnectError/onClose 四个回调。AgentSideConnection:163-170— 用 ACP SDK 的AgentSideConnection包裹 ndjson stream,工厂函数返回AcpGatewayAgent。normalizeAcpInitializeProtocolVersion:175-197— 兼容性 hack:SDK 0.22 严格校验 protocolVersion 是 uint16,有些编辑器会传 MCP 日期字符串,这里强制重写。AcpGatewayAgent 类:250-307— 实现 ACP SDK 的Agent接口,持有 connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer。handleGatewayEvent:356-368— 事件分流:chat→ handleChatEvent,exec.approval.requested→ handleExecApprovalRequestEvent,agent→ handleAgentEvent。initialize:370-395— 返回 agentCapabilities(loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities)。newSession:397-427— 生成 sessionId、解析 sessionKey、startLedgerSession、推送第一个SessionSnapshotUpdate。loadSession:429-490— 三层 ledger replay 兜底:exact-by-sessionId → listed-by-sessionKey → fallback。prompt:664-730— 拼接 cwd 前缀、MAX_PROMPT_BYTES防爆、extractAttachmentsFromPrompt、idempotencyKey: runId、设 pendingPrompt。AcpEventLedger:49-90— ledger 接口,SQLite-backed,默认MAX_SESSIONS=200、MAX_EVENTS_PER_SESSION=5000、MAX_SERIALIZED_BYTES=16MB。LEDGER_VERSION + 文件锁:17-30— 旧文件 ledger 还在,启动时migrateFileAcpEventLedgerToSQLite把它迁移到 SQLite 并归档源文件。event-mapper:1-60— ACPContentBlock/ToolCallContent↔ Gateway 文本/附件/metadata 的双向映射。AcpSession + AcpServerOptions:33-48— session 数据结构 + CLI 选项(gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit)。AcpTranslatorSessionUpdates— 推送SessionUpdate帧,记录到 ledger。
数据流
serveAcpGateway 启动流程(serveAcpGateway:42)是严格的"先 gateway ready 再开 ACP":
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
routeLogsToStderr(); // stdout 留给 ndjson,日志全部走 stderr
const cfg = getRuntimeConfig();
const bootstrap = await resolveGatewayClientBootstrap({ ... });
const gateway = new GatewayClient({
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
onEvent: (evt) => { void agent?.handleGatewayEvent(evt); },
onHelloOk: () => { resolveGatewayReady(); agent?.handleGatewayReconnect(); },
onConnectError: (err) => { rejectGatewayReady(err); },
onClose: (code, reason) => { /* reject or onClosed */ },
});
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
const readiness = await startGatewayClientWhenEventLoopReady(gateway, { ... });
// ...
await gatewayReady.catch((err) => { shutdown(); throw err; });
// ... 然后才起 AgentSideConnection
}关键顺序:日志全部路由到 stderr(routeLogsToStderr:43)——stdout 是 ndjson 通道,任何 console.log 都会污染协议帧。GatewayClient 必须先 hello 成功再开 ACP,否则 IDE 收到的第一个 prompt 会发到还没握手的 gateway。SIGINT/SIGTERM 都触发 shutdown,保证 IDE 关闭时 gateway.stop() 也会调用,避免悬挂的 WebSocket 连接。
prompt 是双向翻译最典型的一处(prompt:664):
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) throw new Error(`Session ${params.sessionId} not found`);
if (session.abortController) {
this.sessionStore.cancelActiveRun(params.sessionId); // 同 session 旧 run 未结束就先取消
}
const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES); // CWE-400: 分块防爆
const attachments = extractAttachmentsFromPrompt(params.prompt);
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd
? `[Working directory: ${displayCwd}]\n\n${userText}` // 让 agent 知道当前工作目录
: userText;
// Defense-in-depth: 也要复核组装后(message 含 cwd 前缀)的总大小
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
throw new Error(`Prompt exceeds maximum allowed size of ${MAX_PROMPT_BYTES} bytes`);
}
const abortController = new AbortController();
const runId = randomUUID();
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
const requestParams = {
sessionKey: session.sessionKey,
message,
attachments: attachments.length > 0 ? attachments : undefined,
idempotencyKey: runId, // Gateway 侧 dedupe
thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
deliver: readBool(params["_meta"], ["deliver"]),
timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
};
// ... 返回 Promise,resolve 由 handleChatEvent/handleAgentEvent 触发
}几个细节:prefixCwd 默认开,把 cwd 信息以人类可读方式塞进 prompt 文本(而不是元数据)——这样 agent 不需要专门处理 _meta 也能知道工作目录;idempotencyKey = runId 保证 IDE 重发同一个 prompt 时 Gateway 不会跑两次;同 session 有活跃 run 时先 cancel(ACP 允许一个 session 同时只有一个 prompt 在跑)。MAX_PROMPT_BYTES 在 extractTextFromPrompt 内部按 block 拒绝超大内容,组装完再复核一遍——这是 defense-in-depth,防止 block 拼接后超过阈值(对应 CWE-400)。
事件回流方向:GatewayClient.onEvent 收到 gateway 事件 → agent.handleGatewayEvent 分流到 chat/approval/agent 三类 handler → handler 调 sessionUpdates.send* 推 SessionUpdate 帧 → sessionUpdates 同时把 update 写进 AcpEventLedger(SQLite)→ IDE 收到 update。
边界与失败
- stdout 污染防御:
routeLogsToStderr()是第一条语句(routeLogsToStderr:43)。任何走console.log的依赖都必须改路由到 stderr,否则日志行会被 ACP SDK 当作协议帧解析,导致连接崩溃。这种坑在第三方 plugin 里很常见。 - protocolVersion 兼容性 hack:
normalizeAcpInitializeProtocolVersion(normalizeAcpInitializeProtocolVersion:175)只对initialize消息的protocolVersion字段做强制规范——ACP SDK 0.22 在 schema 校验前会拒绝非 uint16 值,而某些编辑器(尤其是 MCP 兼容层)会传日期字符串。这里在帧进入 SDK 之前先规范化,避免 IDE 一握手就被 SDK 报错。 - disconnectTimer 与 reconnect:
handleGatewayDisconnect(handleGatewayDisconnect:339)会启动一个 disconnectTimer,disconnect 期间所有 pendingPrompt 既不 resolve 也不 reject——reconcilePendingPrompts在handleGatewayReconnect时按 generation 过滤,只重投还在等且 generation 匹配的 prompt。这避免了"gateway 断了 5 秒,所有 prompt 同时报错给 IDE"的灾难。 - 三层 ledger replay:
loadSession(loadSession:429)按顺序尝试:exact-by-sessionId → listed-by-sessionKey → fallback。前两层都不 complete 时,意味着 sessionId 在 ledger 里不存在或不完整(可能是文件 ledger 时代的旧 session,或 SQLite 迁移中被截断),会触发一次新的readLedgerReplay用sessionId + sessionKey联合查。任何一层 complete 就返回,保证 IDE 重开能尽量恢复状态。 - ledger 容量上限:
DEFAULT_MAX_SESSIONS = 200、DEFAULT_MAX_EVENTS_PER_SESSION = 5_000、DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger 上限:18-20)。超限会逐出最老 session 或截断事件流——这避免了长期运行的 OpenClaw 把 ledger 表写爆。文件 ledger 还有withFileLock8 次指数退避(retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000)和 15s stale 检测,迁移到 SQLite 后这些路径只用于历史数据。 - sessionCreateRateLimiter:
newSession/loadSession(未知 session 时)都会调enforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399),默认固定窗口限流。这防 IDE 因为 bug 或脚本疯狂 newSession 打爆 Gateway 的 session store。 - MAX_PROMPT_BYTES 双重防御:
extractTextFromPrompt在块级别拒绝超大内容,组装成完整 message 后再Buffer.byteLength复核。两层独立检查,即使块级别忘了 limit,组装后也能拦住(CWE-400)。 - ACP 与 OpenClaw agent 解耦:
AcpGatewayAgent只实现 ACP SDK 的Agent接口(implements Agent:250),所有"思考"都在 gateway 后端的 agent 主循环里。ACP 进程崩了不会丢 agent 状态——session/run 记录都在 gateway 里,IDE 重连后loadSession能 replay 回来。
小结
ACP 把 OpenClaw 接入到 IDE 的标准 agent 协议,自己只做翻译——stdio ndjson ↔ Gateway WebSocket,ACP 方法 ↔ chat run,ACP SessionUpdate ↔ gateway 事件帧。serveAcpGateway 是进程入口(先建 GatewayClient 等 hello 再开 ACP),AcpGatewayAgent 是协议翻译器,AcpEventLedger 是 session 重放的 SQLite 持久层。Gateway 那一侧怎么处理 chat/run 请求看 Gateway 核心,真实跑 agent 主循环的细节看 Agent 主循环,ACP 透传的工具调用如何执行看 Capabilities/Tools。ACP 触发的 detached prompt 在 Tasks 里以 runtime: "acp" 出现,可以享受 task 的统一取消/delivery/重放语义。