ACP: IDE-Brücke
Verantwortung
serveAcpGateway (serveAcpGateway:42-173) ist der stdio-Brückenprozess, den OpenClaw für IDEs bereitstellt: ein eigenständiges Node-Programm (gestartet über openclaw acp CLI), das über stdin/stdout das JSON-RPC des Agent Client Protocol spricht und die von der IDE gesendeten Methoden initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession in WebSocket chat/run-Aufrufe des Gateways übersetzt und die vom Gateway gepushten Ereignisse (event) zurück in ACP-SessionUpdate an die IDE wandelt.
Seine zentrale Position ist „Übersetzer (translator) + Mittelsmann": Es betreibt selbst keine Agent-Inferenz, führt keine Werkzeuge direkt aus, speichert keine Langzeit-Memory; es richtet das ACP-Protokoll und das Gateway-Protokoll bidirektional aus. ACP wird von Editoren wie Zed übernommen; über serveAcpGateway kann OpenClaw von diesen IDEs als Standard-Agent-Server eingebunden werden, ohne dass die IDE das OpenClaw-interne Protokoll kennen muss.
Designmotivation
Warum die IDE nicht direkt an das WebSocket des Gateways lassen?
- stdio-freundlich: Der Standardweg für IDEs, einen Agent-Server zu starten, ist einen Subprocess zu forken und über stdin/stdout zu kommunizieren. Das vermeidet TCP-Socket-Lecks beim Schließen der IDE und erfordert von der IDE weder Port noch Token des Gateways — sie braucht nur den Befehl
openclaw acp --gateway-url ... --token-file .... - Protokollunterschiede: ACP nutzt JSON-RPC over ndjson mit Methodennamen wie
initialize/prompt/cancel(Agent-Semantik); das Gateway nutzt WebSocket mit eigenen Ereignisframes (chat/agent/exec.approval.requested). Beide Seiten unterscheiden sich in Granularität, Feldnamen, Fähigkeitsdeklarationen —AcpGatewayAgent(AcpGatewayAgent:250) ist genau für diese Übersetzung zuständig. - Reconnect-Semantik: ACP ist Request-Response — die IDE sendet
promptund wartet auf das Ergebnis; das Gateway ist ein Ereignisstrom, dessen WebSocket mittendrin abreißen und wieder verbinden kann.AcpGatewayAgenthält einependingPrompts-Map (pendingPrompts:258) und indiziert „laufende Prompts" nachsessionId; gehen während des Disconnects Ereignisse verloren, kann nach ReconnectreconcilePendingPrompts(handleGatewayReconnect:329-356) den Zustand wieder ausrichten. - Session-Persistenz und Replay: Nach IDE-Neustart soll die letzte Sitzungshistorie sichtbar sein; ACP bietet
loadSession/listSessions/resumeSession.AcpEventLedger(AcpEventLedger:49-73) schreibt jeden Prompt und jeden SessionUpdate in SQLite, sodass nach Gateway-Neustart replay möglich bleibt.
Schlüsseldateien
serveAcpGateway:42-173— Eintritt: routet Logs nach stderr, baut GatewayClient, wartet auf hello, startetAgentSideConnection.GatewayClient Optionen:83-115—clientName: CLI,clientDisplayName: "ACP",caps: [TOOL_EVENTS]und vier Callbacks onEvent/onHelloOk/onConnectError/onClose.AgentSideConnection:163-170— Wickelt den ndjson-Stream mit dem ACP-SDKAgentSideConnection; Factory liefertAcpGatewayAgent.normalizeAcpInitializeProtocolVersion:175-197— Kompatibilitäts-Hack: SDK 0.22 validiert protocolVersion streng als uint16; manche Editoren übergeben MCP-Datumsstrings, hier wird umgeschrieben.AcpGatewayAgent Klasse:250-307— Implementiert dasAgent-Interface des ACP-SDK; hält connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.handleGatewayEvent:356-368— Ereignis-Dispatch:chat→ handleChatEvent,exec.approval.requested→ handleExecApprovalRequestEvent,agent→ handleAgentEvent.initialize:370-395— Liefert agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).newSession:397-427— Generiert sessionId, löst sessionKey,startLedgerSession, push erstenSessionSnapshotUpdate.loadSession:429-490— Dreischichtiger ledger-replay-Fallback: exact-by-sessionId → listed-by-sessionKey → fallback.prompt:664-730— Setzt cwd-Präfix,MAX_PROMPT_BYTES-Schutz,extractAttachmentsFromPrompt,idempotencyKey: runId, setzt pendingPrompt.AcpEventLedger:49-90— ledger-Interface, SQLite-backed; defaultMAX_SESSIONS=200,MAX_EVENTS_PER_SESSION=5000,MAX_SERIALIZED_BYTES=16MB.LEDGER_VERSION + Dateischloss:17-30— Alte file-ledger existiert noch; beim Start migriertmigrateFileAcpEventLedgerToSQLitesie nach SQLite und archiviert die Quelldatei.event-mapper:1-60— Bidirektionales Mapping zwischen ACPContentBlock/ToolCallContent↔ Gateway Text/Anhänge/Metadaten.AcpSession + AcpServerOptions:33-48— Session-Datenstruktur + CLI-Optionen (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).AcpTranslatorSessionUpdates— PushtSessionUpdate-Frames und zeichnet sie im ledger auf.
Datenfluss
Der Start von serveAcpGateway (serveAcpGateway:42) folgt strikt „erst gateway ready, dann ACP":
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
routeLogsToStderr(); // stdout bleibt ndjson vorbehalten, Logs gehen nach 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; });
// ... dann erst AgentSideConnection starten
}Reihenfolge kritisch: Logs werden nach stderr geroutet (routeLogsToStderr:43) — stdout ist der ndjson-Kanal, jedes console.log würde den Protokollframe verschmutzen. Der GatewayClient muss zuerst erfolgreich hello sein, bevor ACP startet, sonst geht der erste Prompt der IDE an ein Gateway, das noch nicht握handelt hat. SIGINT/SIGTERM lösen beide shutdown aus, sodass beim Schließen der IDE auch gateway.stop() aufgerufen wird — keine offene WebSocket-Verbindung hängen bleiben.
prompt ist die typischste Stelle der bidirektionalen Übersetzung (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); // alter run derselben session noch aktiv -> vorher canceln
}
const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES); // CWE-400: blockweise explode protection
const attachments = extractAttachmentsFromPrompt(params.prompt);
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd
? `[Working directory: ${displayCwd}]\n\n${userText}` // Agent über aktuelles Arbeitsverzeichnis informieren
: userText;
// Defense-in-depth: auch nach der Assemblierung (message enthält cwd-Präfix) Gesamtgröße prüfen
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-seitiges Dedupe
thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
deliver: readBool(params["_meta"], ["deliver"]),
timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
};
// ... Promise zurückgeben; resolve wird durch handleChatEvent/handleAgentEvent ausgelöst
}Details: prefixCwd ist default an und steckt cwd-Information lesbar in den Prompt-Text (statt als Metadatum) — so weiß der Agent auch ohne explizite _meta-Behandlung das Arbeitsverzeichnis; idempotencyKey = runId garantiert, dass ein IDE-Retransmit desselben Prompts das Gateway nicht zweimal laufen lässt; eine aktive Run in derselben Session wird zuerst gecancelt (ACP erlaubt nur einen laufenden Prompt pro Session). MAX_PROMPT_BYTES lehnt in extractTextFromPrompt blockweise zu große Inhalte ab; nach der Assemblierung folgt ein zweiter Check — Defense-in-Depth, damit eine nach Blockprüfung zusammengesetzte Nachricht nicht doch den Schwellwert überschreitet (CWE-400).
Rückfluss: GatewayClient.onEvent empfängt Gateway-Ereignisse → agent.handleGatewayEvent dispatcht an chat/approval/agent-Handler → Handler rufen sessionUpdates.send* und pushen SessionUpdate-Frames → sessionUpdates schreibt das update gleichzeitig ins AcpEventLedger (SQLite) → IDE empfängt update.
Grenzen und Fehler
- stdout-Verschmutzungsschutz:
routeLogsToStderr()ist die erste Anweisung (routeLogsToStderr:43). Jede Abhängigkeit, die überconsole.logausgibt, muss nach stderr geroutet sein, sonst wird die Log-Zeile vom ACP-SDK als Protokollframe geparst und die Verbindung stürzt ab. Diese Falle lauert häufig in Drittanbieter-Plugins. - protocolVersion-Kompatibilitätshack:
normalizeAcpInitializeProtocolVersion(normalizeAcpInitializeProtocolVersion:175) erzwingt nur dasprotocolVersion-Feld derinitialize-Nachricht — ACP-SDK 0.22 lehnt vor der Schema-Validierung nicht-uint16-Werte ab, während manche Editoren (besonders MCP-Kompatibilitätschichten) Datumsstrings übergeben. Hier wird vor Eintritt in das SDK normalisiert, damit die IDE nicht schon beim Handshake vom SDK abgewiesen wird. - disconnectTimer und reconnect:
handleGatewayDisconnect(handleGatewayDisconnect:339) startet einen disconnectTimer; während des Disconnect werden alle pendingPrompts weder resolved noch rejected —reconcilePendingPromptsfiltert beihandleGatewayReconnectnach generation und wirft nur die Prompts neu, die noch warten und deren generation passt. Das verhindert das Disaster „Gateway 5 Sekunden weg, alle Prompts melden gleichzeitig Fehler". - Dreischichtiger ledger-replay:
loadSession(loadSession:429) versucht in Reihenfolge: exact-by-sessionId → listed-by-sessionKey → fallback. Sind die ersten beiden nicht komplett (sessionId im ledger nicht vorhanden oder unvollständig — etwa aus der file-ledger-Ära oder während der SQLite-Migration abgeschnitten), wird ein neuesreadLedgerReplaymitsessionId + sessionKeygesucht. Jede Schicht, die komplett ist, kehrt zurück, sodass die IDE beim Wiederöffnen so viel Zustand wie möglich wiederherstellen kann. - ledger-Kapazitätsobergrenzen:
DEFAULT_MAX_SESSIONS = 200,DEFAULT_MAX_EVENTS_PER_SESSION = 5_000,DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger Obergrenzen:18-20). Bei Überschreitung werden älteste Sessions verdrängt oder Ereignisströme abgeschnitten — das verhindert, dass ein langlaufendes OpenClaw die ledger-Tabelle sprengt. Die file-ledger hat zusätzlichwithFileLockmit 8 mal exponentiellem Backoff (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) und 15s-Stale-Detektion; nach SQLite-Migration werden diese Pfade nur noch für historische Daten genutzt. - sessionCreateRateLimiter:
newSession/loadSession(bei unbekannter Session) rufenenforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399); default ist ein Fixed-Window-Limiter. Verhindert, dass eine IDE mit Bug oder Skript den Session-Store des Gateways mit newSession zumacht. - MAX_PROMPT_BYTES doppelte Verteidigung:
extractTextFromPromptlehnt blockweise zu große Inhalte ab; nach der Assemblierung zur vollständigen message folgt einBuffer.byteLength-Check. Zwei unabhängige Prüfungen — selbst vergisst die Blockprüfung, die Assemblierung fängt (CWE-400). - ACP und OpenClaw-Agent entkoppelt:
AcpGatewayAgentimplementiert nur dasAgent-Interface des ACP-SDK (implements Agent:250); das gesamte „Denken" passiert in der Agent-Hauptschleife auf der Gateway-Backend-Seite. Stürzt der ACP-Prozess ab, geht kein Agent-Zustand verloren — session/run-Datensätze leben im Gateway; nach IDE-Reconnect kannloadSessionreplayen.
Zusammenfassung
ACP bindet OpenClaw in das Standard-Agent-Protokoll der IDE ein und übersetzt nur — stdio ndjson ↔ Gateway WebSocket, ACP-Methoden ↔ chat run, ACP SessionUpdate ↔ Gateway-Ereignisframes. serveAcpGateway ist der Prozesseintritt (erst GatewayClient bauen, hello abwarten, dann ACP starten); AcpGatewayAgent der Protokollübersetzer; AcpEventLedger die SQLite-Persistenzschicht für Session-Replay. Wie die Gateway-Seite chat/run-Anfragen behandelt, siehe Gateway-Kern; die Details der echten Agent-Hauptschleife siehe Agent-Hauptschleife; wie ACP-durchgereichte Werkzeugaufrufe ausgeführt werden, siehe Capabilities/Tools. Detached Prompts, die durch ACP ausgelöst werden, tauchen in Tasks als runtime: "acp" auf und genießen die einheitliche Cancel-/Delivery-/Replay-Semantik von Tasks.