Skip to content

ACP:IDE 橋接

源码版本v2026.6.11

責務

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 を接続させないのか?

  1. stdio フレンドリ:IDE が agent server を起動する標準的做法は子プロセスを fork して stdin/stdout で通信します。これで IDE 終了時に TCP socket がリークせず,IDE が gateway のポート/token を知る必要もありません——IDE は openclaw acp --gateway-url ... --token-file ... というコマンドを知るだけで済みます。
  2. プロトコル差異:ACP は JSON-RPC over ndjson で,メソッド名は initialize/prompt/cancel といった agent セマンティクス;Gateway は WebSocket に自身のイベントフレーム(chat/agent/exec.approval.requested)を載せます。両者のイベント粒度、フィールド命名、能力宣言は完全に異なります——AcpGatewayAgent(AcpGatewayAgent:250)が専らこの翻訳を行うクラスです。
  3. 再接続セマンティクス:ACP はリクエスト-レスポンスモデルで,IDE は prompt を送って結果を待ちます;しかし Gateway はイベントストリームで,WebSocket が途中で切れて再接続することがあります。AcpGatewayAgentpendingPrompts Map(pendingPrompts:258)を維持し,「実行中の prompt」を sessionId で索引し,断線中にイベントを失っても reconnect 後 reconcilePendingPrompts(handleGatewayReconnect:329-356)で再整列できます。
  4. session 永続化と再再生:IDE 再起動後に前回の会話履歴を見たい,ACP プロトコルには loadSession/listSessions/resumeSession があります。AcpEventLedger(AcpEventLedger:49-73)は各 prompt と SessionUpdate を SQLite に落とし,gateway 再起動後も replay できます。

主要ファイル

  • serveAcpGateway:42-173 — 入口:ログを stderr にルーティング,GatewayClient を構築,hello を待ち,AgentSideConnection を起動。
  • GatewayClient 选项:83-115clientName: CLIclientDisplayName: "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 防爆、extractAttachmentsFromPromptidempotencyKey: runId、pendingPrompt 設定。
  • AcpEventLedger:49-90 — ledger インターフェース,SQLite-backed,デフォルト MAX_SESSIONS=200MAX_EVENTS_PER_SESSION=5000MAX_SERIALIZED_BYTES=16MB
  • LEDGER_VERSION + 文件锁:17-30 — 旧ファイル ledger はまだ存在,起動時に migrateFileAcpEventLedgerToSQLite が SQLite に移行しソースファイルをアーカイブ。
  • event-mapper:1-60 — ACP ContentBlock/ToolCallContent ↔ Gateway テキスト/添付/metadata の双方向マッピング。
  • AcpSession + AcpServerOptions:33-48 — session データ構造 + CLI オプション(gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit)。
  • AcpTranslatorSessionUpdatesSessionUpdate フレームをプッシュ,ledger に記録。

データフロー

serveAcpGateway 起動フロー(serveAcpGateway:42)は厳格な「gateway ready を先に,ACP を後に」です:

typescript
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)です:

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);  // 同 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_BYTESextractTextFromPrompt 内部でブロック単位に超大内容を拒否し,組み立て後に再チェック——これは 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 もされません——reconcilePendingPromptshandleGatewayReconnect 時に 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 = 200DEFAULT_MAX_EVENTS_PER_SESSION = 5_000DEFAULT_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 は Tasksruntime: "acp" として現れ,task の統一キャンセル/delivery/再再生セマンティクスを享受できます。