メインコンテンツへスキップ
ブログ一覧

Claude Agent SDK セッション管理実践ガイド — fork・resume・rewindで「巻き戻せるAIエージェント」を作る

(更新: 2026年03月18日)
Claude Agent SDKAIエージェントセッション管理TypeScript

AIエージェントに「このコードをリファクタリングして」と指示したら、盛大に壊してしまった——そんな経験は、エージェント開発をしていれば一度はあるだろう。従来のエージェントは会話が一本道で、失敗したら最初からやり直すしかなかった。

Claude Agent SDKのセッション管理機能を使えば、会話をgitのようにブランチ・巻き戻しできる。本記事では、forkSessionによるA/Bテスト的試行、resumeによる中断再開、rewindFilesによるファイル復元を、実際に動くTypeScriptコードとともに解説する。

セッション管理が解決する3つの課題

リスクのある操作を安全に試したい(fork)

データベースのマイグレーションやコードのリファクタリングなど、破壊的な変更をエージェントに任せるのは怖い。forkSessionを使えば、元のセッションを汚さずに試行できる。失敗したら破棄するだけだ。

長時間タスクを中断・再開したい(resume)

コード分析に30分、修正にさらに30分——途中でプロセスが落ちたら最初からやり直しになる。resumeを使えば、セッションIDを指定して別プロセスから会話を再開できる。

失敗時にファイルを元に戻したい(rewind)

エージェントがファイルを書き換えたあとにビルドが通らない場合、手動でgit checkoutするのは面倒だ。rewindFilesなら、指定した時点の状態にファイルを自動復元できる。

この3つの機能——fork・resume・rewindは、プロダクション品質のエージェントに不可欠な「回復性」を実現する仕組みだ。

セッションの基本:persist と resume

セッションの自動永続化の仕組み

Agent SDKでは、persistSessionオプションがデフォルトでtrueになっている。セッションファイルは~/.claude/projects/<encoded-cwd>/<session-id>.jsonlに自動保存される。特別な設定なしに、すべての会話が永続化されるということだ。

sessionIdによる中断・再開パターン

query()のセッション関連オプションは以下の通り。

オプション 説明
continue boolean 同じプロセス内で会話を継続
resume string 別プロセスからセッションIDで再開
sessionId string セッションIDを明示指定
forkSession boolean 会話履歴をコピーした新セッションを作成
persistSession boolean セッションの永続化(デフォルト: true)
enableFileCheckpointing boolean ファイルチェックポイントの有効化(デフォルト: false)

ここで重要なのはcontinueresumeの違いだ。continueは同一プロセス内での会話継続、resumeはセッションIDを指定して別プロセスから再開する。正直、最初はこの違いに戸惑った。

実用的なパターンとして、read-onlyフェーズで調査→ユーザー確認→writeフェーズで修正という2段階エージェントを紹介する。

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

let sessionId: string | undefined;

// Phase 1: read-onlyで分析
for await (const message of query({
  prompt: "認証モジュールのセキュリティ問題を分析して。修正はまだしないで",
  options: {
    cwd: "/path/to/project",
    allowedTools: ["Read", "Glob", "Grep"],
  },
})) {
  if (message.type === "system" && message.subtype === "init") {
    sessionId = message.session_id;
  }
  if ("result" in message) {
    console.log("分析結果:", message.result);
  }
}

// ユーザーに確認を求める
const approved = await askUserConfirmation("この修正方針で進めますか?");

if (approved && sessionId) {
  // Phase 2: resumeでwrite権限付きで再開
  for await (const message of query({
    prompt: "先ほど特定した問題を修正して",
    options: {
      resume: sessionId,
      allowedTools: ["Read", "Edit", "Write", "Bash"],
      permissionMode: "acceptEdits",
    },
  })) {
    if ("result" in message) {
      console.log("修正完了:", message.result);
    }
  }
}

forkSessionで「失敗しても壊れない」エージェントを作る

forkSessionの仕組みと使いどころ

forkSession: trueを指定すると、既存の会話履歴をコピーした新しいセッションが作られる。元のセッションは一切変更されない。query()のオプションとしてforkSession: trueを渡すだけで利用できる。

使いどころの判断基準はシンプルだ。「元の会話コンテキストを汚したくないか?」——答えがYesならfork、Noならcontinueを使う。

A/Bテスト的エージェント設計パターン

リファクタリングをforkで試行し、ビルド結果で採用/破棄を決定するパターンを見てみよう。

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
import { execSync } from "child_process";

const projectDir = "/path/to/project";
let mainSessionId: string | undefined;

// メインセッションでコードを分析
for await (const message of query({
  prompt: "このプロジェクトの認証ロジックを分析して、リファクタリング方針を提案して",
  options: {
    cwd: projectDir,
    allowedTools: ["Read", "Glob", "Grep"],
  },
})) {
  if (message.type === "system" && message.subtype === "init") {
    mainSessionId = message.session_id;
  }
}

// forkしてリファクタリングを試行
let forkSucceeded = false;
if (mainSessionId) {
  for await (const message of query({
    prompt: "提案した方針に基づいてリファクタリングを実行して",
    options: {
      resume: mainSessionId,
      forkSession: true,  // 元セッションを保護
      cwd: projectDir,
      allowedTools: ["Read", "Edit", "Write", "Bash"],
      permissionMode: "acceptEdits",
    },
  })) {
    if ("result" in message) {
      // ビルドで検証
      try {
        execSync("npm run build", { cwd: projectDir, stdio: "pipe" });
        execSync("npm test", { cwd: projectDir, stdio: "pipe" });
        forkSucceeded = true;
        console.log("リファクタリング成功 — 変更を採用");
      } catch {
        console.log("ビルド/テスト失敗 — 変更を破棄");
        execSync("git checkout .", { cwd: projectDir });
      }
    }
  }
}

if (!forkSucceeded) {
  // 元のセッションは無傷なので、別のアプローチを試せる
  console.log("元のセッションから別の方針で再試行可能");
}

このパターンが地味に便利で、メインセッションのコンテキスト(分析結果や方針)を保持したまま、何度でも試行錯誤できる。

rewindFilesでファイル変更を安全に巻き戻す

チェックポイントの有効化と巻き戻し手順

rewindFilesを使うには、enableFileCheckpointing: trueを指定する必要がある。rewindFilesquery()が返すQueryオブジェクトのメソッドとして呼び出す。

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

const queryInstance = query({
  prompt: "テストカバレッジを向上させるためにテストファイルを追加して",
  options: {
    cwd: "/path/to/project",
    allowedTools: ["Read", "Edit", "Write"],
    permissionMode: "acceptEdits",
    enableFileCheckpointing: true,  // チェックポイントを有効化
  },
});

for await (const message of queryInstance) {
  if ("result" in message) {
    // ビルドで検証
    try {
      execSync("npm test", { cwd: "/path/to/project", stdio: "pipe" });
      console.log("テスト追加成功");
    } catch {
      // 失敗時はrewindで復元(userMessageIdを指定して任意の時点に戻せる)
      await queryInstance.rewindFiles(userMessageId);
      console.log("ファイルを変更前の状態に復元しました");
    }
  }
}

rewindの制約と回避策

重要な制約がある。

rewindFilesWrite、Edit、NotebookEditの3ツール経由の変更のみを追跡する。Bash経由のファイル変更(echo > file等)は追跡されない。

Bash経由の変更が追跡されない問題の回避策として、エージェントのプロンプトでこれらのツールの使用を強制する方法がある。

typescript
for await (const message of query({
  prompt: `ファイルの変更はすべてEditツールを使って行うこと。
echoリダイレクトやsedコマンドでのファイル書き換えは禁止。
タスク: CSSモジュールをTailwindに移行して`,
  options: {
    enableFileCheckpointing: true,
    allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"],
    // Bashを除外してEditツール使用を強制する方法もある
  },
})) {
  // ...
}

実践:3つの設計パターン

パターン1:段階的権限昇格(read → confirm → write)

初回は読み取り専用で分析し、ユーザーの承認後に書き込み権限付きでresumeする。前述のコード例がまさにこのパターンだ。安全性と効率を両立できるため、プロダクション環境での利用に最適。

パターン2:投機的実行(fork → validate → adopt/discard)

2つの異なるアプローチを並行してforkで試行し、テスト結果の良い方を採用するパターン。

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
import { execSync } from "child_process";

const projectDir = "/path/to/project";
let baseSessionId: string | undefined;

// ベースセッションで分析
for await (const message of query({
  prompt: "パフォーマンスのボトルネックを特定して、2つの改善方針を提案して",
  options: {
    cwd: projectDir,
    allowedTools: ["Read", "Glob", "Grep"],
  },
})) {
  if (message.type === "system" && message.subtype === "init") {
    baseSessionId = message.session_id;
  }
}

if (!baseSessionId) throw new Error("セッションID取得失敗");

// アプローチA: キャッシュ戦略で改善
const tryApproach = async (approach: string, label: string) => {
  // git stashで作業ディレクトリをクリーンに保つ
  execSync("git stash", { cwd: projectDir });
  
  for await (const message of query({
    prompt: `方針「${approach}」で実装して`,
    options: {
      resume: baseSessionId,
      forkSession: true,
      cwd: projectDir,
      allowedTools: ["Read", "Edit", "Write", "Bash"],
      permissionMode: "acceptEdits",
    },
  })) {
    if ("result" in message) {
      try {
        execSync("npm run build", { cwd: projectDir, stdio: "pipe" });
        const testOutput = execSync("npm test -- --reporter=json", {
          cwd: projectDir,
          encoding: "utf-8",
        });
        return { label, success: true, result: testOutput };
      } catch (e) {
        return { label, success: false, result: String(e) };
      } finally {
        execSync("git checkout .", { cwd: projectDir });
        execSync("git stash pop", { cwd: projectDir });
      }
    }
  }
  return { label, success: false, result: "no result" };
};

const resultA = await tryApproach("キャッシュ層の追加", "A");
const resultB = await tryApproach("クエリの最適化", "B");

// 結果を比較して採用
const winner = resultA.success && !resultB.success ? resultA
  : !resultA.success && resultB.success ? resultB
  : resultA; // 両方成功なら任意の基準で選択

console.log(`採用: アプローチ ${winner.label}`);

パターン3:長時間タスクの中断・再開(persist → resume)

sessionIdを外部ストレージに保存し、プロセス再起動後もresumeで再開するパターン。launchdやcronで動くデーモンに最適。

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
import Database from "better-sqlite3";

const db = new Database("./sessions.db");
db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (
  task_id TEXT PRIMARY KEY,
  session_id TEXT NOT NULL,
  status TEXT DEFAULT 'running'
)`);

async function startOrResume(taskId: string, prompt: string) {
  const row = db.prepare(
    "SELECT session_id FROM agent_sessions WHERE task_id = ? AND status = 'running'"
  ).get(taskId) as { session_id: string } | undefined;

  const options = row
    ? { resume: row.session_id, allowedTools: ["Read", "Edit", "Write", "Bash"] as const }
    : { allowedTools: ["Read", "Edit", "Write", "Bash"] as const };

  for await (const message of query({ prompt, options })) {
    if (message.type === "system" && message.subtype === "init") {
      db.prepare(
        "INSERT OR REPLACE INTO agent_sessions (task_id, session_id, status) VALUES (?, ?, 'running')"
      ).run(taskId, message.session_id);
    }
    if ("result" in message) {
      db.prepare(
        "UPDATE agent_sessions SET status = 'completed' WHERE task_id = ?"
      ).run(taskId);
      return message.result;
    }
  }
}

判断フロー

どのパターンを使うかは以下で判断できる。

  • ユーザー確認が必要 → パターン1(段階的権限昇格)
  • 破壊的変更を伴い、複数の方法を試したい → パターン2(投機的実行)
  • プロセスをまたいで作業を継続したい → パターン3(中断・再開)

よくあるハマりポイントと対策

resumeで「セッションが見つからない」

セッションファイルのパスは~/.claude/projects/<encoded-cwd>/<session-id>.jsonlだ。cwdが異なるとパスも変わるため、resumeする際は元のセッションと同じcwdを指定すること。また、persistSession: falseを設定していないか確認しよう。

Bash経由の変更がrewindで戻らない

前述の通り、rewindFilesはWrite、Edit、NotebookEditの3ツール経由の変更のみを追跡する。allowedToolsからBashを除外するか、プロンプトでこれらのツールの使用を強制するのが確実な対策だ。

forkしたセッションが肥大化する

forkは会話履歴をまるごとコピーするため、大量にforkするとディスクを圧迫する。不要なforkセッションを定期的にクリーンアップする仕組みを入れておくとよい。listSessions()でセッション一覧を取得し、古いものを削除するスクリプトを用意しておこう。

continueとresumeの混同

continue: trueは同じプロセス内の会話継続、resumeはセッションIDを指定した別プロセスからの再開だ。プロセスをまたぐ場合は必ずresumeを使うこと。


Claude Agent SDKのセッション管理は、エージェントに「やり直す力」を与える。fork・resume・rewindの3機能を組み合わせることで、失敗に強く、中断に強く、安全に試行錯誤できるエージェントが構築できる。まずはresumeによる中断再開から始め、慣れたらforkSessionによる投機的実行に進むのがおすすめだ。

もっと読む他の技術記事も読む