ACP: puente con el IDE
Responsabilidad
serveAcpGateway (serveAcpGateway:42-173) es el proceso puente stdio que OpenClaw ofrece al IDE: es un programa Node independiente (se arranca desde la CLI openclaw acp), habla JSON-RPC de Agent Client Protocol sobre stdin/stdout, y traduce los métodos que el IDE envía (initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession, etc.) a llamadas WebSocket chat/run del Gateway, y los eventos (event) que el Gateway empuja se traducen de vuelta a SessionUpdate de ACP para el IDE.
Su posición clave es la de «traductor (translator) + intermediario»: no hace inferencia del agent, no ejecuta herramientas directamente, no almacena memoria a largo plazo; solo alinea el protocolo ACP y el protocolo del Gateway en ambos sentidos. ACP es un protocolo adoptado por editores como Zed, así que a través de serveAcpGateway OpenClaw puede exponerse a estos IDEs como un agent server estándar, sin que el IDE necesite conocer el protocolo interno de OpenClaw.
Motivación de diseño
¿Por qué no dejar que el IDE se conecte directamente al WebSocket del gateway?
- stdio-friendly: la forma estándar de un IDE para arrancar un agent server es forkear un subproceso y comunicarse por stdin/stdout. Esto evita leaks de TCP socket al cerrar el IDE y no exige que el IDE conozca puerto/token del gateway — solo necesita conocer el comando
openclaw acp --gateway-url ... --token-file .... - Diferencias de protocolo: ACP usa JSON-RPC sobre ndjson, con nombres de método
initialize/prompt/cancelcon semántica de agent; el Gateway usa WebSocket con frames de eventos propios (chat/agent/exec.approval.requested). La granularidad de eventos, los nombres de campos y las declaraciones de capacidad son completamente distintos en cada lado —AcpGatewayAgent(AcpGatewayAgent:250) es la clase que específicamente hace esta traducción. - Semántica de reconexión: ACP es request-response, el IDE envía
prompty espera el resultado; pero el Gateway es un flujo de eventos, el WebSocket puede caerse y reconectarse a mitad.AcpGatewayAgentmantiene un MappendingPrompts(pendingPrompts:258) indexa «prompts en curso» porsessionId, y si se pierden eventos durante la desconexión,reconcilePendingPrompts(handleGatewayReconnect:329-356) realinea el estado tras reconnect. - Persistencia y replay de sesión: tras reiniciar el IDE, se quiere ver el historial de la sesión anterior; ACP tiene
loadSession/listSessions/resumeSession.AcpEventLedger(AcpEventLedger:49-73) persiste cada prompt y SessionUpdate a SQLite, permitiendo replay aunque el gateway se reinicie.
Archivos clave
serveAcpGateway:42-173— entrada: rutea logs a stderr, construye GatewayClient, espera hello, arrancaAgentSideConnection.GatewayClient options:83-115—clientName: CLI,clientDisplayName: "ACP",caps: [TOOL_EVENTS], con cuatro callbacks onEvent/onHelloOk/onConnectError/onClose.AgentSideConnection:163-170— envuelve el ndjson stream conAgentSideConnectiondel ACP SDK, la factory devuelveAcpGatewayAgent.normalizeAcpInitializeProtocolVersion:175-197— hack de compatibilidad: el SDK 0.22 valida estrictamente que protocolVersion sea uint16, algunos editores mandan strings de fecha estilo MCP, aquí se sobreescribe.AcpGatewayAgent class:250-307— implementa la interfazAgentdel ACP SDK, mantiene connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.handleGatewayEvent:356-368— dispatch de eventos:chat→ handleChatEvent,exec.approval.requested→ handleExecApprovalRequestEvent,agent→ handleAgentEvent.initialize:370-395— devuelve agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).newSession:397-427— genera sessionId, resuelve sessionKey,startLedgerSession, empuja el primerSessionSnapshotUpdate.loadSession:429-490— tres capas de fallback en ledger replay: exact-by-sessionId → listed-by-sessionKey → fallback.prompt:664-730— monta el prefijo cwd,MAX_PROMPT_BYTESanti overflow,extractAttachmentsFromPrompt,idempotencyKey: runId, set del pendingPrompt.AcpEventLedger:49-90— interfaz del ledger, SQLite-backed, por defectoMAX_SESSIONS=200,MAX_EVENTS_PER_SESSION=5000,MAX_SERIALIZED_BYTES=16MB.LEDGER_VERSION + file lock:17-30— si aún existe un ledger de archivos, al arrancarmigrateFileAcpEventLedgerToSQLitelo migra a SQLite y archiva el archivo origen.event-mapper:1-60— mapeo bidireccional entreContentBlock/ToolCallContentde ACP y texto/adjuntos/metadata del Gateway.AcpSession + AcpServerOptions:33-48— estructura de datos de sesión + opciones CLI (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).AcpTranslatorSessionUpdates— empuja framesSessionUpdatey los registra en el ledger.
Flujo de datos
El flujo de arranque de serveAcpGateway (serveAcpGateway:42) sigue estrictamente «primero gateway ready, luego abre ACP»:
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
routeLogsToStderr(); // stdout queda para ndjson, logs van a 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; });
// ... recién aquí arranca AgentSideConnection
}Orden clave: los logs se rutean a stderr (routeLogsToStderr:43) — stdout es el canal ndjson, cualquier console.log contamina el frame del protocolo. GatewayClient debe completar hello antes de abrir ACP, si no, el primer prompt que el IDE envíe iría a un gateway aún sin handshake. SIGINT/SIGTERM ambos disparan shutdown, garantizando que al cerrar el IDE se llame gateway.stop() y no queden WebSockets colgados.
prompt es el punto más típico de traducción bidireccional (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); // si el run anterior no terminó, se cancela primero
}
const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES); // CWE-400: rechaza por bloques
const attachments = extractAttachmentsFromPrompt(params.prompt);
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd
? `[Working directory: ${displayCwd}]\n\n${userText}` // deja que el agent sepa el directorio actual
: userText;
// Defense-in-depth: volver a chequear el tamaño total ya montado (message con prefijo 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, // dedupe en Gateway
thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
deliver: readBool(params["_meta"], ["deliver"]),
timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
};
// ... devuelve una Promise cuyo resolve lo disparan handleChatEvent/handleAgentEvent
}Varios detalles: prefixCwd está on por defecto, inserta la info de cwd en el texto del prompt de forma legible (no como metadata) — así el agent no necesita procesar _meta para conocer el directorio; idempotencyKey = runId garantiza que si el IDE reenvía el mismo prompt, el Gateway no lo ejecute dos veces; si hay un run activo en la misma sesión, se cancela primero (ACP permite un solo prompt en curso por sesión). MAX_PROMPT_BYTES se aplica dentro de extractTextFromPrompt por bloque para rechazar contenido enorme, y tras montar el mensaje se vuelve a chequear — defense-in-depth, previene que la concatenación de bloques supere el umbral (CWE-400).
Dirección de retorno de eventos: GatewayClient.onEvent recibe el evento del gateway → agent.handleGatewayEvent lo dispatcha a los handlers chat/approval/agent → el handler llama a sessionUpdates.send* para empujar un frame SessionUpdate → sessionUpdates también escribe el update al AcpEventLedger (SQLite) → el IDE recibe el update.
Límites y fallos
- Defensa contra contaminación de stdout:
routeLogsToStderr()es la primera sentencia (routeLogsToStderr:43). Cualquier dependencia que useconsole.logdebe rutearla a stderr, si no, una línea de log será parseada como frame por el ACP SDK y tirará la conexión. Este pozo es común en plugins de terceros. - Hack de compatibilidad de protocolocolVersion:
normalizeAcpInitializeProtocolVersion(normalizeAcpInitializeProtocolVersion:175) normaliza forzadamente el campoprotocolVersiondel mensajeinitialize— el ACP SDK 0.22 rechaza valores no uint16 antes de la validación del schema, y algunos editores (especialmente capas de compatibilidad MCP) mandan strings de fecha. Aquí se normaliza antes de que el frame entre al SDK, evitando que el IDE falle el handshake. - disconnectTimer y reconnect:
handleGatewayDisconnect(handleGatewayDisconnect:339) arranca un disconnectTimer, durante el cual todos los pendingPrompt ni se resuelven ni se rechazan —reconcilePendingPromptsfiltra por generation al reconectar, y solo reinyecta los prompts que aún esperan y cuya generation coincide. Evita el desastre de «gateway cae 5 segundos y todos los prompts sueltan error al IDE a la vez». - Tres capas de ledger replay:
loadSession(loadSession:429) intenta en orden: exact-by-sessionId → listed-by-sessionKey → fallback. Si las dos primeras no están complete, significa que el sessionId no está en el ledger o está incompleto (podría ser una sesión vieja del era file-ledger, o truncada en migración a SQLite), y dispara un nuevoreadLedgerReplaycombinandosessionId + sessionKey. Si cualquiera de las capas completa, devuelve, garantizando que el IDE pueda recuperar estado en la reapertura. - Límites de capacidad del ledger:
DEFAULT_MAX_SESSIONS = 200,DEFAULT_MAX_EVENTS_PER_SESSION = 5_000,DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger limits:18-20). Al superarse, se evicta la sesión más vieja o se trunca el flujo de eventos — evita que un OpenClaw corriendo a largo plazo explote la tabla del ledger. El file ledger adicionalmente usawithFileLockcon 8 retries exponenciales (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) y detección de stale a 15s; tras migrar a SQLite estas rutas solo se usan para datos históricos. - sessionCreateRateLimiter:
newSession/loadSession(cuando la sesión es desconocida) llamanenforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399), con rate limit fijo por ventana por defecto. Previene que un IDE con bug o un script bombardee el session store del Gateway con newSession. - Defensa en dos capas de MAX_PROMPT_BYTES:
extractTextFromPromptrechaza contenido enorme a nivel de bloque, y tras montar elmessagecompleto se vuelve a chequear conBuffer.byteLength. Dos chequeos independientes, si el nivel de bloque olvidara el límite, el montaje lo atrapa (CWE-400). - ACP desacoplado del agent de OpenClaw:
AcpGatewayAgentsolo implementa la interfazAgentdel ACP SDK (implements Agent:250), todo el «pensamiento» vive en el bucle principal del agent en el backend del gateway. Si el proceso ACP cae, no se pierde estado del agent — session/run viven en el gateway, y al reconectar el IDEloadSessionpuede replay.
Resumen
ACP integra OpenClaw en el protocolo estándar de agent del IDE, haciendo solo de traductor — stdio ndjson ↔ WebSocket del Gateway, métodos ACP ↔ chat run, SessionUpdate ACP ↔ frames de evento del gateway. serveAcpGateway es la entrada del proceso (construye GatewayClient primero, espera hello, luego abre ACP), AcpGatewayAgent es el traductor de protocolo, AcpEventLedger es la capa de persistencia SQLite para replay de sesión. Cómo el Gateway maneja las peticiones chat/run en Núcleo del gateway; los detalles del bucle principal del agent en Runner embebido; cómo se ejecutan las herramientas que ACP pasa a través en Herramientas. Los prompts detached disparados por ACP aparecen en Tasks con runtime: "acp", pudiendo aprovechar las semánticas unificadas de cancelación/delivery/replay del task.