ACP: pont IDE
Responsabilités
serveAcpGateway (serveAcpGateway:42-173) est le process de pont stdio d'OpenClaw pour les IDE: un programme Node indépendant (lancé depuis le CLI openclaw acp), qui fait tourner le JSON-RPC Agent Client Protocol sur stdin/stdout, traduit les méthodes initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession envoyées par l'IDE en appels WebSocket chat/run de la Gateway, et retraduit les événements (event) Gateway en SessionUpdate ACP poussés à l'IDE.
Sa position clé est « traducteur (translator) + homme du milieu »: il ne fait pas d'inférence agent, n'exécute pas directement d'outils, ne stocke pas de mémoire long-terme; il aligne juste bidirectionnellement le protocole ACP et le protocole Gateway. ACP est adopté par Zed et d'autres éditeurs; via serveAcpGateway, OpenClaw peut être branché par ces IDE comme un agent server standard, sans que l'IDE connaisse le protocole interne OpenClaw.
Motivation de conception
Pourquoi ne pas laisser l'IDE se connecter directement au WebSocket gateway?
- stdio friendly: la pratique standard pour qu'un IDE lance un agent server est de forker un sous-process, communiquer via stdin/stdout. Cela évite de leak des TCP socket à la fermeture de l'IDE, et ne demande pas à l'IDE de connaître port/token de la gateway — il suffit que l'IDE connaisse la commande
openclaw acp --gateway-url ... --token-file .... - Différences de protocole: ACP utilise JSON-RPC over ndjson, les méthodes sont
initialize/prompt/cancelen sémantique agent; Gateway utilise WebSocket avec ses propres frames d'événements (chat/agent/exec.approval.requested). Granularité d'événements, nommage de champs, déclaration de capacités totalement différents des deux côtés —AcpGatewayAgent(AcpGatewayAgent:250) est la classe dédiée à cette traduction. - Sémantique reconnect: ACP est requête-réponse, l'IDE envoie un
promptet attend le résultat; mais Gateway est un flux d'événements, le WebSocket peut se rompre et se reconnecter en cours.AcpGatewayAgentmaintient une MappendingPrompts(pendingPrompts:258) indexant les « prompts en cours » parsessionId; si des événements sont perdus pendant la déconnexion,reconcilePendingPrompts(handleGatewayReconnect:329-356) réaligne l'état après reconnect. - Persistance et replay de session: après restart IDE, on veut revoir l'historique de session; le protocole ACP a
loadSession/listSessions/resumeSession.AcpEventLedger(AcpEventLedger:49-73) écrit chaque prompt et SessionUpdate en SQLite, après restart gateway le replay reste possible.
Fichiers clés
serveAcpGateway:42-173— Entrée: route les logs vers stderr, construit GatewayClient, attend hello, lanceAgentSideConnection.GatewayClient options:83-115—clientName: CLI,clientDisplayName: "ACP",caps: [TOOL_EVENTS], quatre callbacks onEvent/onHelloOk/onConnectError/onClose.AgentSideConnection:163-170— Enveloppe le ndjson stream avec leAgentSideConnectiondu SDK ACP, la factory retourneAcpGatewayAgent.normalizeAcpInitializeProtocolVersion:175-197— Hack de compat: le SDK 0.22 valide strictement protocolVersion comme uint16, certains éditeurs passent une chaîne de date MCP, on la réécrit forcément.AcpGatewayAgent class:250-307— Implémente l'interfaceAgentdu SDK ACP, détient connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.handleGatewayEvent:356-368— Dispatch d'événements:chat→ handleChatEvent,exec.approval.requested→ handleExecApprovalRequestEvent,agent→ handleAgentEvent.initialize:370-395— Renvoie agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).newSession:397-427— Génère sessionId, parse sessionKey,startLedgerSession, pousse le premierSessionSnapshotUpdate.loadSession:429-490— Replis ledger trois niveaux: exact-by-sessionId → listed-by-sessionKey → fallback.prompt:664-730— Concatène le préfixe cwd, protectionMAX_PROMPT_BYTES,extractAttachmentsFromPrompt,idempotencyKey: runId, set pendingPrompt.AcpEventLedger:49-90— Interface ledger, SQLite-backed, defaultsMAX_SESSIONS=200,MAX_EVENTS_PER_SESSION=5000,MAX_SERIALIZED_BYTES=16MB.LEDGER_VERSION + file lock:17-30— Le vieux file ledger est encore là, au démarragemigrateFileAcpEventLedgerToSQLitemigre vers SQLite et archive le fichier source.event-mapper:1-60— Mapping bidirectionnel ACPContentBlock/ToolCallContent↔ Gateway text/attachments/metadata.AcpSession + AcpServerOptions:33-48— Structure de données session + options CLI (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).AcpTranslatorSessionUpdates— Pousse les framesSessionUpdate, les enregistre dans le ledger.
Flux de données
Le flow de démarrage serveAcpGateway (serveAcpGateway:42) suit strictement « gateway ready d'abord, ACP ensuite »:
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
routeLogsToStderr(); // stdout réservé au ndjson, logs tous vers 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; });
// ... puis seulement alors on lance AgentSideConnection
}L'ordre clé: les logs sont tous routés vers stderr (routeLogsToStderr:43) — stdout est le canal ndjson, n'importe quel console.log polluerait les frames de protocole. GatewayClient doit d'abord réussir hello avant d'ouvrir ACP, sinon le premier prompt envoyé par l'IDE atterrirait sur une gateway pas encore handshakée. SIGINT/SIGTERM déclenchent tous deux shutdown, garantissant qu'à la fermeture de l'IDE gateway.stop() soit appelé aussi, pour éviter des connexions WebSocket pendantes.
prompt est l'endroit le plus typique de la traduction bidirectionnelle (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 l'ancien run de la même session n'est pas terminé, on cancel d'abord
}
const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES); // CWE-400: protection anti-explosion par blocs
const attachments = extractAttachmentsFromPrompt(params.prompt);
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd
? `[Working directory: ${displayCwd}]\n\n${userText}` // pour que l'agent connaisse le cwd courant
: userText;
// Defense-in-depth: re-vérifier la taille totale après assemblage (message inclut le préfixe 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, // dédoublonnage côté Gateway
thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
deliver: readBool(params["_meta"], ["deliver"]),
timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
};
// ... retourne une Promise, résolue par handleChatEvent/handleAgentEvent
}Plusieurs détails: prefixCwd est on par défaut, ce qui pousse l'info cwd dans le texte du prompt de façon lisible (pas en metadata) — ainsi l'agent n'a pas besoin de traiter spécifiquement _meta pour connaître le working directory; idempotencyKey = runId garantit que si l'IDE renvoie le même prompt, la Gateway ne le lance pas deux fois; si une session a déjà un run actif, on cancel d'abord (ACP n'autorise qu'un seul prompt à la fois par session). MAX_PROMPT_BYTES est vérifié dans extractTextFromPrompt au niveau bloc pour rejeter les contenus trop gros, puis revérifié après assemblage — defense-in-depth, pour qu'un bloc qui aurait oublié le limit soit quand même intercepté après assemblage (CWE-400).
Sens retour d'événements: GatewayClient.onEvent reçoit l'événement gateway → agent.handleGatewayEvent dispatch vers les handlers chat/approval/agent → le handler appelle sessionUpdates.send* pour pousser un frame SessionUpdate → sessionUpdates écrit aussi l'update dans AcpEventLedger (SQLite) → l'IDE reçoit l'update.
Limites et modes d'échec
- Défense contre pollution stdout:
routeLogsToStderr()est la première instruction (routeLogsToStderr:43). Toute dépendance qui passerait parconsole.logdoit être reroutée vers stderr, sinon la ligne de log serait parsée comme une frame de protocole par le SDK ACP et casserait la connexion. Ce piège est fréquent dans les plugins tiers. - Hack de compat protocolVersion:
normalizeAcpInitializeProtocolVersion(normalizeAcpInitializeProtocolVersion:175) ne normalise que le champprotocolVersiondu messageinitialize— le SDK ACP 0.22 rejette les valeurs non uint16 avant la validation schema, et certains éditeurs (spécialement la couche compat MCP) passent une chaîne de date. On normalise donc avant que la frame n'entre dans le SDK, pour éviter que l'IDE ne se fasse rejeter dès le handshake. - disconnectTimer et reconnect:
handleGatewayDisconnect(handleGatewayDisconnect:339) lance un disconnectTimer; pendant la déconnexion, tous les pendingPrompt ne sont ni résolus ni rejetés —reconcilePendingPromptsàhandleGatewayReconnectfiltre par génération et ne renvoie que les prompts encore en attente avec génération matchant. Cela évite la catastrophe « gateway déconnecté 5s, tous les prompts remontent une erreur à l'IDE ». - Replay ledger trois niveaux:
loadSession(loadSession:429) essaie dans l'ordre: exact-by-sessionId → listed-by-sessionKey → fallback. Si les deux premiers ne sont pas complete, cela signifie que le sessionId n'existe pas en ledger ou est incomplet (peut-être une session de l'ère file ledger, ou tronquée pendant la migration SQLite), on déclenche unreadLedgerReplayavecsessionId + sessionKeyen joint. Dès qu'un niveau est complete, on retourne, pour maximiser la récupération d'état à la réouverture IDE. - Limites de capacité ledger:
DEFAULT_MAX_SESSIONS = 200,DEFAULT_MAX_EVENTS_PER_SESSION = 5_000,DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger caps:18-20). Au-delà, on évince la session la plus ancienne ou on tronque le flux d'événements — pour éviter qu'une OpenClaw au long cours ne fasse exploser la table ledger. Le file ledger a aussiwithFileLock8 retries backoff exponentiel (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) et détection stale 15s; après migration SQLite ces chemins ne servent qu'aux données historiques. - sessionCreateRateLimiter:
newSession/loadSession(quand la session est inconnue) appellentenforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399), limite par fenêtre fixe par défaut. Empêche un IDE qui bugge ou qui script de faire exploser le session store de la Gateway à coups de newSession. - Double défense MAX_PROMPT_BYTES:
extractTextFromPromptrejette au niveau bloc les contenus trop gros, puis après assemblage en message complet on re-vérifie viaBuffer.byteLength. Deux checks indépendants, même si le bloc oublie le limit, l'assemblage intercepte (CWE-400). - Découplage ACP et agent OpenClaw:
AcpGatewayAgentn'implémente que l'interfaceAgentdu SDK ACP (implements Agent:250), toute la « pensée » est dans la boucle principale agent du backend gateway. Si le process ACP crashe, l'état agent n'est pas perdu — les enregistrements session/run sont côté gateway, l'IDE après reconnect peutloadSessionpour replay.
Résumé
ACP branche OpenClaw dans le protocole agent standard des IDE, ne fait que traduire — stdio ndjson ↔ Gateway WebSocket, méthodes ACP ↔ chat run, ACP SessionUpdate ↔ frames d'événements gateway. serveAcpGateway est l'entrée process (construit d'abord GatewayClient, attend hello, puis ouvre ACP), AcpGatewayAgent est le traducteur de protocole, AcpEventLedger est la couche de persistance SQLite pour le replay de session. Côté Gateway, comment sont gérées les requêtes chat/run dans cœur de la Gateway, le détail de l'exécution réelle de la boucle principale agent dans boucle principale de l'agent, comment les appels d'outils transmis par ACP sont exécutés dans Capabilities/Tools. Le detached prompt déclenché par ACP apparaît dans Tasks avec runtime: "acp", bénéficiant des sémantiques unifiées cancel/delivery/replay de task.