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 の 4 コールバック。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— 3 層 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 が 1 行でもプロトコルフレームを汚染します。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 に cwd を伝える
: 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 は 2 度走りません;同 session に活発な run がある場合は先に cancel します(ACP は 1 session で同時に 1 つの prompt しか走らせない)。MAX_PROMPT_BYTES は extractTextFromPrompt 内部でブロック単位に超大内容を拒否し,組み立て後に再チェック——これは defense-in-depth で,ブロック結合後に閾値を超えるのを防ぎます(CWE-400 対応)。
イベント還流方向:GatewayClient.onEvent が gateway イベントを受信 → agent.handleGatewayEvent が chat/approval/agent の 3 ハンドラに分流 → ハンドラが sessionUpdates.send* で SessionUpdate フレームをプッシュ → sessionUpdates は同時に update を AcpEventLedger(SQLite)に書き込み → IDE が update を受信。
境界と失敗
- stdout 汚染防御:
routeLogsToStderr()は最初のステートメント(routeLogsToStderr:43)です。console.logに走る依存はすべて stderr に再ルーティングしなければならず,さもなくばログ行が ACP SDK にプロトコルフレームとして解析され,接続が崩壊します。この種の罠はサードパーティプラグインでよくあります。 - 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 にエラー報告」という災難を回避します。 - 3 層 ledger replay:
loadSession(loadSession:429)は順に試行:exact-by-sessionId → listed-by-sessionKey → fallback。前 2 層が incomplete の場合,sessionId が ledger に存在しないか不完全(ファイル ledger 時代の旧 session,または SQLite 移行で切断された可能性)を意味し,sessionId + sessionKeyで联合照会する新しいreadLedgerReplayをトリガーします。どれか 1 層で 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 にはwithFileLockの 8 回指数バックオフ(retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000)と 15s stale 検出もあり,SQLite 移行後はこれらのパスは履歴データにだけ使われます。 - sessionCreateRateLimiter:
newSession/loadSession(未知 session 時)は両方enforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399)を呼び,デフォルトは固定ウィンドウ制限です。これで IDE のバグやスクリプトで newSession が爆発して Gateway の session store を叩き潰すのを防ぎます。 - MAX_PROMPT_BYTES 二重防御:
extractTextFromPromptはブロックレベルで超大内容を拒否し,完全な message に組み立て後にBuffer.byteLengthで再チェック。2 層独立チェックで,ブロックレベルで 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/再再生セマンティクスを享受できます。