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/重放語意。