Claude Media
spawnClaudeCodeProcessでClaude Codeを別環境で起動する

spawnClaudeCodeProcessでClaude Codeを別環境で起動する

TypeScript Agent SDKのspawnClaudeCodeProcessは、Claude Codeの起動を自前の関数に差し替える口です。SpawnedProcessの条件、signalの挙動、コンテナ向けの実装例を解説します。

spawnClaudeCodeProcess は、TypeScript版Agent SDKの Options にあるオプションです。SDKが内部で行っているClaude Codeプロセスの起動を、自分で書いた関数に差し替えられます。型は (options: SpawnOptions) => SpawnedProcess で、用途はVM、コンテナ、リモート環境でClaude Codeを動かすことです。

要は、SDKを呼ぶホストと、Claude Codeが動く場所を分けるための口です。この記事では、差し替え関数が満たすべき型、signal の扱い、コンテナで動かす実装の組み立て方を順に見ます。

使う前の前提

対象はTypeScript版のAgent SDK(@anthropic-ai/claude-agent-sdk)で、Node.js 18以上が必要です。SDKには claude のネイティブバイナリが同梱されるので、ホスト側にCLIを別途入れる必要はありません。一方、差し替え先のコンテナやVMでは、その中で claude が実行できる状態にしておく必要があります。prewarm() と併用するなら、SDKはv0.3.282以降です。

何を差し替えるオプションなのか

query() を呼ぶと、SDKは claude というCLIプロセスを起動し、標準入出力(stdio)で会話します。このプロセスがシェルと作業ディレクトリ、ディスク上のセッション記録を持ちます。構造の全体像はAgent SDK本番ホスティングにあります。

spawnClaudeCodeProcess を指定すると、この「起動」の部分だけが自前の関数に変わります。query() の呼び出し側のコードは変わりません。

項目未指定のとき指定したとき
型未指定のときなし(既定は undefined)指定したとき(options: SpawnOptions) => SpawnedProcess
Claude Codeの起動場所未指定のときSDKを呼ぶホストのローカル指定したとき関数が返すプロセスの先
実行ファイルの解決未指定のとき同梱のネイティブバイナリを自動解決指定したとき関数の中で自分で決める

実行ファイルを別パスにしたいだけなら、このオプションは要りません。pathToClaudeCodeExecutable に claude バイナリのパスを渡せば足ります。こちらは同梱バイナリが入らなかった環境や、対応外のプラットフォーム向けです。

SpawnedProcessが求める条件

差し替え関数が返すオブジェクトは、SpawnedProcess インターフェースを満たす必要があります。型定義は次のとおりです。

interface SpawnedProcess {
  stdin: Writable;
  stdout: Readable;
  readonly killed: boolean;
  readonly exitCode: number | null;
  kill(signal: NodeJS.Signals): boolean;
  on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
  on(event: "error", listener: (error: Error) => void): void;
  // once / off も同じ2イベント分が定義されている
}

必要なのは、書き込み用の stdin、読み取り用の stdout、終了状態の2つのプロパティ、kill()、そして exit と error の2イベントの購読です。stderr は含まれていません。

ChildProcess はこのインターフェースをすでに満たします。Node.jsの spawn() が返す値は、そのまま返して構いません。自前の実装が要るのは、返すものが ChildProcess ではないとき、たとえばWebSocketやSSH越しの接続をstdioのように見せたいときです。

SpawnOptionsに入ってくる値

SDKが関数に渡す SpawnOptions は5項目です。

フィールド型役割
command型string役割起動するコマンド
args型string[]役割コマンドの引数
cwd型string(省略可)役割作業ディレクトリ
env型Record<string, string | undefined>役割環境変数
signal型AbortSignal役割プロセスを片づける合図

command と args に具体的に何が入るかは、型定義の下に説明がありません。最初の実装ではまず中身をログに出し、実際の値を見てから転送の仕方を決めるのが確実です。

import { spawn } from "node:child_process";
import { query } from "@anthropic-ai/claude-agent-sdk";
 
for await (const message of query({
  prompt: "リポジトリの構成を説明して",
  options: {
    spawnClaudeCodeProcess: (opts) => {
      console.error("command:", opts.command);
      console.error("args:", opts.args);
      console.error("cwd:", opts.cwd);
      return spawn(opts.command, opts.args, {
        cwd: opts.cwd,
        env: opts.env,
        signal: opts.signal,
        stdio: ["pipe", "pipe", "pipe"],
      });
    },
  },
})) {
  console.log(message);
}

この形は、未指定の場合とほぼ同じ動きをする出発点です。ChildProcess を返すだけなので SpawnedProcess の条件も満たします。ここから spawn の行を、自分の基盤に合わせて書き換えていきます。

コンテナで動かす実装の組み立て方

Docker上の既存コンテナにClaude Codeを置く場合の例を示します。公式のサンプルではなく、上の SpawnedProcess の条件から組んだ一例です。Options.env を指定すると、サブプロセスの環境は process.env とマージされず丸ごと置き換わります。opts.env にはSDK側で決まった環境変数が入ってくるので、そこから必要なキーだけを -e で転送します。

import { spawn } from "node:child_process";
import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
 
const FORWARD_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_AGENT_SDK_CLIENT_APP"];
 
const spawnInContainer = (opts: SpawnOptions): SpawnedProcess => {
  const envArgs = FORWARD_KEYS.flatMap((key) => {
    const value = opts.env[key];
    return value === undefined ? [] : ["-e", `${key}=${value}`];
  });
 
  return spawn(
    "docker",
    ["exec", "-i", ...envArgs, "-w", "/workspace", "agent-box", "claude", ...opts.args],
    { signal: opts.signal, stdio: ["pipe", "pipe", "pipe"] },
  );
};

ここで押さえる点は3つあります。

  • stdioをつなぎ続ける: -i を付けて標準入力を開いたままにします。SDKはstdio越しに会話するので、ここが閉じると応答が来ません
  • 環境変数は明示的に渡す: ホスト側の opts.env は docker exec に自動では届きません。必要な変数は -e KEY=VALUE で渡します。APIキーのような秘密は、コンテナ側に置くかプロキシで注入する設計がセキュアデプロイの解説にあります
  • args の転送は実測で決める: opts.args をそのまま渡すか、一部を除くかは、前節のログで確かめてから決めます

SSH越しに別マシンで起動する場合も、形は同じです。ssh コマンドを spawn で起動すれば、ChildProcess の stdin と stdout がそのままリモートのClaude Codeにつながります。

const spawnOverSsh = (opts: SpawnOptions): SpawnedProcess =>
  spawn("ssh", ["agent-host", "claude", ...opts.args], {
    signal: opts.signal,
    stdio: ["pipe", "pipe", "pipe"],
  });

こちらも opts.cwd と opts.env はリモートに自動では届きません。作業ディレクトリが要るなら、リモート側で cd してから claude を呼ぶ形にします。args の中身を実測する前提も同じです。

コンテナそのものの堅牢化(--cap-drop ALL、--network none、読み取り専用マウントなど)は別の話で、spawnClaudeCodeProcess はそこに関与しません。起動先をどう囲うかは、サンドボックス設定のチュートリアルと合わせて決めます。

signalは「すぐ」には発火しない

signal の挙動には注意が要ります。

手順

abort時のsignalが発火するまで

  1. 1

    呼び出し側がabortする

    Options.abortController が中断されます。

  2. 2

    SDKがstdinを閉じる

    CLIがきれいに終了できるよう、約2秒待ちます。

  3. 3

    signalが発火する

    ここで初めて SpawnOptions.signal が中断されます。

つまり、signal は「最後の片づけの合図」です。Node.jsの spawn() に渡せば、そのタイミングでプロセスが止まります。VMやコンテナの破棄処理に渡す場合も同じです。

呼び出し側のabortに即座に反応したいなら、関数の外側にある自分の abortController.signal を購読します。たとえばリモートのセッションを素早く解放したいときに使えます。

const ac = new AbortController();
 
ac.signal.addEventListener("abort", () => {
  // 約2秒を待たずに、リモート側の後始末を始める
  void releaseRemoteSandbox();
});
 
query({ prompt, options: { abortController: ac, spawnClaudeCodeProcess } });

releaseRemoteSandbox は自分で書く後始末の関数です。stdinが閉じてCLIが終了処理に入る猶予が欲しいなら、opts.signal のほうに任せます。

prewarmと組み合わせるときのcwd

アプリの起動時にClaude Codeを待機させる prewarm() にも、この差し替えは関係します。spawnClaudeCodeProcess でClaude Codeを別マシンやコンテナに置く場合、options.cwd にはそちら側に存在するディレクトリを指定します。prewarm() は待機中のプロセスを options.cwd に置くためです。

待機中の予備プロセスは、約230〜260MBのメモリを使います。リモート側のリソースを確保するときの目安になります。prewarm() は公式ページでAlphaと表示され、TypeScript Agent SDK v0.3.282以降が必要です。

起動を分けたときに変わること

起動場所が変わると、プロセスが持つ状態の置き場も変わります。Claude Codeのプロセスは、シェル、作業ディレクトリ、JSONL形式のセッション記録を持ちます。差し替えた先のコンテナやVMが、これらを保持する側になります。

  • セッション記録: 起動先のディスクに残るため、コンテナを破棄すると失われます。別ホストで再開したいなら、sessionStore で外部ストレージに写す設計が必要です。詳しくはセッションの再開とフォークを参照してください
  • 作業ディレクトリ: cwd は起動先で有効なパスでなければなりません。ホスト側のパスをそのまま渡すと、存在しないことがあります
  • リソース: エージェント1つあたり1GiB RAM、5GiBディスク、1CPUを出発点の目安にできます。セッション長と同時実行数で増えるので、実測で調整します

差し替えを段階的に確かめる順番

いきなり本番の基盤に載せず、変える要素を1つずつ増やすと、失敗したときの原因が絞れます。

手順

確認の進め方

  1. 1

    ローカルで素通しにする

    出発点のコード(spawn をそのまま返す形)で、通常どおり応答が返ることを確かめます。ここで command と args の実際の値が分かります。

  2. 2

    起動先だけを変える

    docker exec や ssh に置き換え、env は転送せずに動かします。Options.stderr に認証まわりのメッセージが出れば、転送が足りていないと分かります。

  3. 3

    必要な環境変数を足す

    転送するキーを1つずつ増やします。一括で渡すより、不要な秘密を起動先に置かずに済みます。

  4. 4

    abortと破棄を試す

    実行中に abortController を中断し、起動先のプロセスが残らないことを確かめます。残る場合は、signal を後始末に渡し忘れていないかを見ます。

標準エラーを拾う

SpawnedProcess に stderr は含まれないため、起動先のエラー出力は Options.stderr コールバックで受けます。型は (data: string) => void です。

query({
  prompt,
  options: {
    spawnClaudeCodeProcess: spawnInContainer,
    stderr: (data) => console.error("[claude stderr]", data),
  },
});

起動に失敗する原因の多くは、このコールバックに出るメッセージで見当がつきます。

起動先で既知の起動失敗が起きたときは、Claude Codeが終了前に error_during_execution の結果を書き出します。この結果には、標準エラーと同じ文面の errors 配列が入ります(Agent SDK v0.3.274以降)。env に CLAUDE_CODE_STARTUP_FAILURE_RESULTS を 1 で渡すと、すべての起動失敗の理由でこの結果を受け取れます。リモート環境では標準エラーを直接見にくいので、アプリ側で原因を表示したいときに役立ちます。

動かないときの切り分け

症状疑う点確かめ方
応答がいつまでも返らない疑う点stdinが閉じている(docker exec の -i 欠落など)確かめ方起動コマンドに標準入力を開く指定があるか見る
起動直後に終了する疑う点起動先に cwd のディレクトリが存在しない確かめ方起動先で ls して、options.cwd のパスが有効か確かめる
認証やモデル呼び出しに失敗する疑う点env が転送されていない確かめ方Options.stderr の出力と、起動先の環境変数を見比べる
abortしても止まらない疑う点signal が約2秒遅れて発火する確かめ方前節のとおり、自前の abortController.signal を購読する

Pythonでは使えるのか

Python SDKのリファレンスページには、spawnClaudeCodeProcess に相当するオプションの記載がありません。本記事の内容はTypeScript版が対象です。Python版で起動場所を分けたい場合は、SDKを呼ぶプロセスごとコンテナやVMに置く構成という選択肢があります。

つまずきやすい点

  • SpawnedProcess に stderr は含まれません。標準エラーを拾いたいときは、Options.stderr コールバックを使います
  • command と args の中身を想定で書かない。最初にログで確認します
  • リモートで動かす場合、env の内容は自動では転送されません。渡す変数は意識して選びます
  • 実行ファイルのパスを変えるだけなら pathToClaudeCodeExecutable で足ります。起動を差し替えるのは、実行場所そのものを分けるときです

まとめ

spawnClaudeCodeProcess は、Claude Codeの実行場所をSDKの呼び出し側から切り離すための差し替え口です。返すものは ChildProcess で足り、自前実装が必要なのはstdioに見せかける接続だけです。signal はabortから約2秒遅れて発火する点と、起動先でセッション記録が保持される点を、設計の前提にしておくと扱いやすくなります。

この記事を共有:XはてブLinkedIn