Skip to content

ACP:IDE 橋接

源码版本v2026.6.11

職責

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?

  1. stdio 友善:IDE 啟動 agent server 的標準做法是 fork 一個子進程、用 stdin/stdout 通訊。這避免 IDE 關閉時洩漏 TCP socket,也不要求 IDE 知道 gateway 的連接埠/token——只需要 IDE 知道 openclaw acp --gateway-url ... --token-file ... 這條指令。
  2. 協定差異:ACP 用 JSON-RPC over ndjson,方法名是 initialize/prompt/cancel 這些 agent 語意;Gateway 用 WebSocket 加自己的事件訊框 (chat/agent/exec.approval.requested)。兩邊的事件粒度、欄位命名、能力宣告完全不同——AcpGatewayAgent(AcpGatewayAgent:250)就是專門做這個翻譯的類別。
  3. 斷線重連語意:ACP 是請求-回應模型,IDE 發 prompt 等結果;但 Gateway 是事件流,WebSocket 可能中途斷開重連。AcpGatewayAgent 維護 pendingPrompts Map(pendingPrompts:258),把「在跑的 prompt」按 sessionId 索引,斷線期間事件丟失了能在 reconnect 後 reconcilePendingPrompts(handleGatewayReconnect:329-356)重新對齊狀態。
  4. session 持久化與重放:IDE 重啟後想看到上次的會話歷史,ACP 協定有 loadSession/listSessions/resumeSessionAcpEventLedger(AcpEventLedger:49-73)把每個 prompt 和 SessionUpdate 落到 SQLite,gateway 重啟後仍能 replay。

關鍵檔案

  • serveAcpGateway:42-173 — 入口:路由日誌到 stderr、建 GatewayClient、等 hello、起 AgentSideConnection
  • GatewayClient 選項:83-115clientName: CLIclientDisplayName: "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 防爆、extractAttachmentsFromPromptidempotencyKey: runId、設 pendingPrompt。
  • AcpEventLedger:49-90 — ledger 介面,SQLite-backed,預設 MAX_SESSIONS=200MAX_EVENTS_PER_SESSION=5000MAX_SERIALIZED_BYTES=16MB
  • LEDGER_VERSION + 檔案鎖:17-30 — 舊檔案 ledger 還在,啟動時 migrateFileAcpEventLedgerToSQLite 把它遷移到 SQLite 並封存原始碼檔。
  • event-mapper:1-60 — ACP ContentBlock/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」:

typescript
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):

typescript
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_BYTESextractTextFromPrompt 內部按 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——reconcilePendingPromptshandleGatewayReconnect 時按 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 遷移中被截斷),會觸發一次新的 readLedgerReplaysessionId + sessionKey 聯合查。任何一層 complete 就回傳,保證 IDE 重開能盡量恢復狀態。
  • ledger 容量上限:DEFAULT_MAX_SESSIONS = 200DEFAULT_MAX_EVENTS_PER_SESSION = 5_000DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger 上限:18-20)。超限會逐出最舊 session 或截斷事件流——這避免了長期執行的 OpenClaw 把 ledger 表寫爆。檔案 ledger 還有 withFileLock 8 次指數退避(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/重放語意。