Skip to content

ACP: IDE bridge

源码版本v2026.6.11

Responsibilities

serveAcpGateway (serveAcpGateway:L42-L173) is OpenClaw's stdio bridge process for IDEs: it's a standalone Node program (launched from the openclaw acp CLI) that speaks Agent Client Protocol JSON-RPC over stdin/stdout, translating IDE-sent initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession methods into Gateway WebSocket chat/run calls, and translating Gateway-pushed events back into ACP SessionUpdate pushed to the IDE.

Its key positioning is "translator + middleman": it does no agent inference itself, doesn't run tools directly, and doesn't store long-term memory. It only bidirectionally aligns the ACP protocol with the Gateway protocol. ACP is adopted by editors like Zed, so through serveAcpGateway OpenClaw can be plugged into these IDEs as a standard agent server, without the IDE needing to know OpenClaw's internal protocol.

Design motivation

Why not let the IDE connect directly to the gateway's WebSocket?

  1. stdio-friendly: the standard way for IDEs to launch an agent server is to fork a subprocess and communicate over stdin/stdout. This avoids leaking TCP sockets when the IDE closes, and doesn't require the IDE to know the gateway's port/token — only the command openclaw acp --gateway-url ... --token-file ....
  2. protocol differences: ACP uses JSON-RPC over ndjson with method names like initialize/prompt/cancel (agent semantics); Gateway uses WebSocket with its own event frames (chat/agent/exec.approval.requested). The event granularity, field naming, and capability declarations are completely different — AcpGatewayAgent (AcpGatewayAgent:L250) is the class that does this translation.
  3. reconnect semantics: ACP is request-response — the IDE sends a prompt and waits for the result; but Gateway is an event stream, and the WebSocket might disconnect and reconnect mid-stream. AcpGatewayAgent maintains a pendingPrompts Map (pendingPrompts:L258) indexing "in-flight prompts" by sessionId; if events are lost during disconnect, reconcilePendingPrompts (handleGatewayReconnect:L329-L356) re-aligns state after reconnect.
  4. session persistence and replay: when the IDE restarts and wants to see the previous session history, the ACP protocol has loadSession/listSessions/resumeSession. AcpEventLedger (AcpEventLedger:L49-L73) persists each prompt and SessionUpdate to SQLite, so after a gateway restart they can still be replayed.

Key files

  • serveAcpGateway:L42-L173 — entry: routes logs to stderr, builds GatewayClient, waits for hello, starts AgentSideConnection.
  • GatewayClient options:L83-L115clientName: CLI, clientDisplayName: "ACP", caps: [TOOL_EVENTS], four callbacks: onEvent/onHelloOk/onConnectError/onClose.
  • AgentSideConnection:L163-L170 — wraps the ndjson stream with ACP SDK's AgentSideConnection; the factory returns AcpGatewayAgent.
  • normalizeAcpInitializeProtocolVersion:L175-L197 — compat hack: SDK 0.22 strictly validates protocolVersion as uint16; some editors pass MCP date strings — forced rewrite here.
  • AcpGatewayAgent class:L250-L307 — implements ACP SDK's Agent interface; holds connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.
  • handleGatewayEvent:L356-L368 — event routing: chat → handleChatEvent, exec.approval.requested → handleExecApprovalRequestEvent, agent → handleAgentEvent.
  • initialize:L370-L395 — returns agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).
  • newSession:L397-L427 — generates sessionId, resolves sessionKey, startLedgerSession, pushes first SessionSnapshotUpdate.
  • loadSession:L429-L490 — three-layer ledger replay fallback: exact-by-sessionId → listed-by-sessionKey → fallback.
  • prompt:L664-L730 — assembles cwd prefix, MAX_PROMPT_BYTES defense, extractAttachmentsFromPrompt, idempotencyKey: runId, sets pendingPrompt.
  • AcpEventLedger:L49-L90 — ledger interface, SQLite-backed, defaults MAX_SESSIONS=200, MAX_EVENTS_PER_SESSION=5000, MAX_SERIALIZED_BYTES=16MB.
  • LEDGER_VERSION + file lock:L17-L30 — legacy file ledger still exists; migrateFileAcpEventLedgerToSQLite migrates it to SQLite at startup and archives the source file.
  • event-mapper:L1-L60 — bidirectional mapping between ACP ContentBlock/ToolCallContent and Gateway text/attachments/metadata.
  • AcpSession + AcpServerOptions:L33-L48 — session data structure + CLI options (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).
  • AcpTranslatorSessionUpdates — pushes SessionUpdate frames, records to ledger.

Data flow

The serveAcpGateway startup flow (serveAcpGateway:L42) strictly follows "gateway ready first, then start ACP":

typescript
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
  routeLogsToStderr();                              // stdout reserved for ndjson; all logs to 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; });
  // ... then start AgentSideConnection
}

Key order: all logs routed to stderr (routeLogsToStderr:L43) — stdout is the ndjson channel; any console.log pollutes protocol frames. GatewayClient must complete hello successfully before starting ACP; otherwise the IDE's first prompt would hit a gateway that hasn't handshaked. SIGINT/SIGTERM both trigger shutdown, ensuring gateway.stop() is called when the IDE closes, avoiding dangling WebSocket connections.

prompt is the most typical two-way translation point (prompt:L664):

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);  // cancel prior active run on same session first
  }
  const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);  // CWE-400: chunk-level defense
  const attachments = extractAttachmentsFromPrompt(params.prompt);
  const displayCwd = shortenHomePath(session.cwd);
  const message = prefixCwd
    ? `[Working directory: ${displayCwd}]\n\n${userText}`   // let the agent know the cwd
    : userText;
  // Defense-in-depth: also re-check assembled size (message includes cwd prefix)
  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-side dedupe
    thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
    deliver: readBool(params["_meta"], ["deliver"]),
    timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
  };
  // ... return Promise; resolve triggered by handleChatEvent/handleAgentEvent
}

A few details: prefixCwd defaults on, putting cwd info into the prompt text in a human-readable way (not as metadata) — so the agent doesn't need to specifically handle _meta to know the working directory; idempotencyKey = runId ensures the Gateway doesn't run twice when the IDE resends the same prompt; when the same session has an active run, cancel first (ACP allows only one prompt running per session at a time). MAX_PROMPT_BYTES rejects oversized content at the block level inside extractTextFromPrompt, and the assembled result is re-checked — this is defense-in-depth, preventing blocks from exceeding the threshold after concatenation (CWE-400).

Event flow direction: GatewayClient.onEvent receives gateway events → agent.handleGatewayEvent routes to three handlers: chat/approval/agent → handlers call sessionUpdates.send* to push SessionUpdate frames → sessionUpdates also writes the update to AcpEventLedger (SQLite) → IDE receives the update.

Boundaries and failures

  • stdout pollution defense: routeLogsToStderr() is the first statement (routeLogsToStderr:L43). Any dependency using console.log must be rerouted to stderr, otherwise log lines get parsed as protocol frames by the ACP SDK, crashing the connection. This pitfall is common in third-party plugins.
  • protocolVersion compat hack: normalizeAcpInitializeProtocolVersion (normalizeAcpInitializeProtocolVersion:L175) only force-normalizes the protocolVersion field of initialize messages — ACP SDK 0.22 rejects non-uint16 values before schema validation, and some editors (especially MCP compat layers) pass date strings. This normalizes before the frame enters the SDK, avoiding the IDE being rejected by the SDK at handshake.
  • disconnectTimer and reconnect: handleGatewayDisconnect (handleGatewayDisconnect:L339) starts a disconnectTimer; during disconnect, no pendingPrompt resolves or rejects — reconcilePendingPrompts filters by generation on handleGatewayReconnect, re-injecting only prompts still waiting with matching generation. This avoids the disaster of "gateway disconnects 5 seconds, all prompts error to IDE simultaneously".
  • three-layer ledger replay: loadSession (loadSession:L429) tries in order: exact-by-sessionId → listed-by-sessionKey → fallback. When the first two aren't complete (the sessionId isn't in ledger or is incomplete — maybe an old session from the file-ledger era, or truncated during SQLite migration), a fresh readLedgerReplay queries jointly with sessionId + sessionKey. If any layer is complete, it returns, ensuring the IDE can recover as much state as possible on reopen.
  • ledger capacity limits: DEFAULT_MAX_SESSIONS = 200, DEFAULT_MAX_EVENTS_PER_SESSION = 5_000, DEFAULT_MAX_SERIALIZED_BYTES = 16 MB (ledger limits:L18-L20). Exceeding evicts the oldest session or truncates the event stream — this prevents long-running OpenClaw from blowing up the ledger table. The file ledger also has withFileLock with 8 exponential-backoff retries (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) and a 15s stale detection; after SQLite migration these paths are only used for historical data.
  • sessionCreateRateLimiter: newSession/loadSession (when session unknown) both call enforceSessionCreateRateLimit (enforceSessionCreateRateLimit:L399) — a default fixed-window rate limit. This prevents an IDE from hammering the Gateway's session store with a flood of newSession calls due to a bug or script.
  • MAX_PROMPT_BYTES double defense: extractTextFromPrompt rejects oversized content at the block level; after assembling into a complete message, Buffer.byteLength re-checks. Two independent checks — even if block-level forgot to limit, the assembled check catches it (CWE-400).
  • ACP decoupled from OpenClaw agent: AcpGatewayAgent only implements ACP SDK's Agent interface (implements Agent:L250); all "thinking" is in the gateway backend's agent main loop. An ACP process crash doesn't lose agent state — session/run records are all in the gateway; after IDE reconnects, loadSession can replay them.

Summary

ACP plugs OpenClaw into the IDE's standard agent protocol, only doing translation — stdio ndjson ↔ Gateway WebSocket, ACP methods ↔ chat run, ACP SessionUpdate ↔ gateway event frames. serveAcpGateway is the process entry (build GatewayClient first, wait for hello, then start ACP); AcpGatewayAgent is the protocol translator; AcpEventLedger is the SQLite persistence layer for session replay. How the gateway handles chat/run requests is in Gateway core; the details of actually running the agent main loop are in Agent main loop; how tool calls passed through by ACP are executed is in Capabilities/Tools. Detached prompts triggered by ACP appear in Tasks with runtime: "acp", enjoying unified task cancel/delivery/replay semantics.