Claude Media
Agent SDKでsession_state_changedを受け取る設定と3つの状態

Agent SDKでsession_state_changedを受け取る設定と3つの状態

CLAUDE_CODE_EMIT_SESSION_STATE_EVENTSを1にすると、Agent SDKのストリームにrunning・idle・requires_actionの状態が流れます。読むときの注意点も挙げます。

session_state_changedは環境変数を立てたときだけ流れる

session_state_changed は、Claude Codeがセッションの状態を報告するときにメッセージストリームへ出すシステムメッセージです。既定では流れてきません。環境変数 CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS を 1 にしたときだけ、ストリームに加わります。

この変数が効く実行形態は2つです。

実行形態必要な条件
Agent SDK必要な条件環境変数を 1 にするだけ
CLIの非対話実行必要な条件--print、--output-format stream-json、--verbose の3つをそろえる

CLIで --verbose を付け忘れると、メッセージは出ません。

メッセージの型はTypeScriptのリファレンスにそのまま載っています。

type SDKSessionStateChangedMessage = {
  type: "system";
  subtype: "session_state_changed";
  state: "idle" | "running" | "requires_action";
  uuid: UUID;
  session_id: string;
};

SDKMessage のユニオン型にも SDKSessionStateChangedMessage が含まれます。受け取る側は type === "system" かつ subtype === "session_state_changed" で絞り込めば十分です。

3つの状態が何を意味するか

state に入る値は3つだけです。

状態

state の3つの値

  • running

    セッションが作業中です。

  • idle

    Claude Codeが次のプロンプトを待っています。

  • requires_action

    ホスト側に送ったリクエストへの回答待ちで止まっています。権限確認のプロンプトが典型例です。

requires_action は「Claudeが考えている」でも「入力待ち」でもありません。自分のアプリが何かに答えないと先へ進めない状態です。画面にスピナーを出し続けると、ユーザーが承認ボタンを押せないまま固まって見えます。この状態だけは、UI上で目立たせる価値があります。

Agent SDKで受け取る最小コード

SDKではサブプロセスの環境を env オプションで渡します。env を指定すると process.env とのマージではなく置き換えになるので、PATH などを失わないよう展開して渡します。

import { query } from "@anthropic-ai/claude-agent-sdk";
 
const stream = query({
  prompt: "テストを実行して失敗を要約して",
  options: {
    env: {
      ...process.env,
      CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
    },
  },
});
 
for await (const message of stream) {
  if (message.type === "system" && message.subtype === "session_state_changed") {
    console.log(`state=${message.state} session=${message.session_id}`);
  }
}

シェルから export CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 してからSDKを起動しても、環境が継承されるなら同じ結果になるはずです。ただし本番では、コード内で env に明示するほうが、どの環境で動いても挙動が変わりません。

上のコードは公式リファレンスの型定義をもとに組んだ例です。実行結果の出力例は示していません。自分の環境で一度流して、状態の並びを確かめてください。

Pythonで受け取る場合の違い

PythonのSDKには session_state_changed 専用のクラスがありません。専用のデータクラスを持たないサブタイプは、SystemMessage(subtype と data を持つ)として届きます。状態の値は message.data["state"] から読みます。

env の扱いもTypeScriptと逆です。TypeScriptの env は子プロセスの環境を置き換えるのに対し、Pythonの env は引き継いだ環境の上にマージされます。PATH を展開して渡す必要はなく、立てたい変数だけを書けば足ります。

import asyncio
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, SystemMessage
 
options = ClaudeAgentOptions(
    env={"CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS": "1"},
)
 
async def main():
    async with ClaudeSDKClient(options=options) as client:
        await client.query("テストを実行して失敗を要約して")
        async for message in client.receive_messages():
            if isinstance(message, SystemMessage) and message.subtype == "session_state_changed":
                print(message.data["state"])
 
asyncio.run(main())

落とし穴は、ループの選び方です。receive_response() は ResultMessage で止まるので、result より後に届いた session_state_changed を取りこぼします。状態を追うなら receive_messages() を使い、ループを抜ける条件は自分で決めます。

CLIの非対話実行で確かめる

SDKを組む前に、CLIで状態の並びを眺める方法もあります。次のコマンドは3点セットをそろえ、状態メッセージだけを jq で抜き出す例です。

CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 \
  claude -p "package.json の scripts を一覧にして" \
  --output-format stream-json --verbose \
  | jq -c 'select(.subtype == "session_state_changed") | .state'

jq は別途インストールが必要です。出力は1行1状態になります。

権限確認が要る作業を頼んでも、requires_action が出るとは限りません。-p で canUseTool も --permission-prompt-tool も付けていない実行は、確認が要る操作を拒否して先へ進みます。ホスト側に答える相手がいないので、ストリームに現れるのは permission_denied メッセージ側です。requires_action を観察したいときは、canUseTool を持つSDKアプリか、--permission-prompt-tool で渡したMCPツールを用意します。idle と result の順序を自分の環境で確かめたいときも、同じコマンドが使えます。select の条件から .subtype を外して .type を併せて出せば、状態メッセージと result が届いた順に並びます。1回の実行で順序が決まって見えても、リファレンスは順序を保証していないので、そこからコードの前提を作らないでください。権限確認の経路はAgent SDKのcanUseToolの記事にまとめています。

requires_actionが立つ条件と、立たない条件

requires_action は「ホストに送った質問へ答えが返るまで止まる」状態です。質問を受ける側がいなければ、立つ余地がありません。権限確認の扱いごとに見ると、次の表のようになります。

権限確認の扱い確認が要る操作の結果
canUseTool コールバックあり(permissionPrompts は既定の host)確認が要る操作の結果コールバックへ送られ、答えが返るまで待つ
--permission-prompt-tool でMCPツールを指定確認が要る操作の結果そのツールへ送られ、答えを待つ
どちらも無い -p / query()確認が要る操作の結果拒否され、実行は続く
--permission-prompts none(v2.1.259以降)確認が要る操作の結果コールバックやMCPツールがあっても拒否される

状態の並びは、コールバックがある実行ならおおよそ次の順になります。

流れ

canUseToolで確認を待つときの状態

  1. 1

    running

    Claudeが作業し、確認の要る操作に当たります。

  2. 2

    requires_action

    canUseTool に質問が渡り、答えが返るまで実行が止まります。自前UIでは、ここで承認ダイアログを出します。

  3. 3

    running

    コールバックが許可か拒否を返すと、作業が再開します(再開の合図としてこの状態が来ることは、型の説明から読み取れる範囲の推測です)。

  4. 4

    idle / result

    ターンが終わると idle と result が届きます。届く順序は決まっていません。

無人運用で requires_action を待ち続けないための手段が --permission-prompts none です。確認が要る操作はコールバックを呼ばずに拒否され、Claudeには「承認できる人がいないので再試行しないこと」が伝わります。

現在の状態として読む(遷移として読まない)

メッセージ名は state_changed ですが、遷移の通知として扱うと誤ります。リファレンスは、同じ状態が複数回報告されることがあるので、1通を「現在の状態」として読むよう書いています。

これは実装に直結します。

  • idle が2通続いても、2回目のターンが終わったとは限りません
  • 前回と同じ値なら何もしない、という判定をコード側に入れておく
  • 「runningからidleへ変わった」ことをトリガーにする処理は、重複して動く

状態の通知をそのままジョブの完了トリガーにすると、二重実行の原因になります。

let last: string | undefined;
for await (const message of stream) {
  if (message.type === "system" && message.subtype === "session_state_changed") {
    if (message.state === last) continue; // 同じ状態の再通知は無視
    last = message.state;
    updateUi(message.state); // 自前のUI更新関数(例)
  }
}

idleとresultは順不同で届く

もう1つの注意点が順序です。1ターンの idle メッセージと result メッセージは、どちらが先に届くか決まっていません。

result を待ってから idle を見る、という前提でコードを書くと、ターン終了直後の取りこぼしや二重処理につながります。result だけを待つ実装なら順序に左右されず、idle だけを待つ実装は、result の中身を読む前にターンを閉じてしまう恐れがあります。次のどちらかに寄せると安全です。

  • ターンの完了判定は result メッセージだけで行い、idle は表示の更新に使う
  • 両方がそろった時点で完了とみなす

バックグラウンド作業がある間のidle

バックグラウンドのサブエージェントやワークフローが動いている間、idle を出すかどうかは別の環境変数 CLAUDE_CODE_BG_TASKS_REPORT_RUNNING で変わります。

  • 非対話セッションの既定は、ターンが終わってもバックグラウンド作業が生きている間はrunningを報告し続ける動き
  • 0 にすると、作業が残っていてもターン終了のたびにidleを報告する
  • 既定の動きと 0 による切り替えにはClaude Code v2.1.269以降が必要。それ以前の版では 1 を設定するとrunningを保持する
  • 開発サーバーのようなバックグラウンドのシェルコマンドは、runningの保持に数えない

この変数は非対話セッションの話で、対話セッションの画面表示とは関係しません。なお、バックグラウンドのシェルコマンドが動いているだけの間は、通常どおりターン終了で idle が届きます。

リモートのセッション一覧のように状態を監視する画面では、既定のままのほうが「Claudeは入力待ち」という誤表示が出にくくなります。逆に、ターンの区切りごとに次のプロンプトを送りたい自動化では、0 にしてidleを拾う設計もありえます。どちらが合うかは、バックグラウンド作業を使うかで決まります。

idleのあとに届く通知ターンの見分け方

idle は「もう何も起きない」という意味ではありません。バックグラウンドの作業が終わると、完了通知がセッションに届いて新しいターンが始まることがあります。このターンは人が打ったプロンプトではないので、running に戻っても「ユーザーの操作の結果」と決めつけられません。

見分けるには、SDKUserMessage か SDKResultMessage の origin.kind が "task-notification" かを確かめます。通知の文面での判定は、リファレンスが勧めていません。通知の発生元は同じ origin の subkind で分かります。状態メッセージだけで「いまのターンは誰が始めたか」を決めるコードは、バックグラウンド作業を使った途端に崩れます。

挙動を左右するバージョンの条件

状態まわりの挙動は、Claude Codeのバージョンで変わる箇所がいくつかあります。手元の動きが記事と合わないときは、先に版を疑ってください。

挙動必要なバージョン
permission_denied メッセージが、コールバックなしの実行でも出る必要なバージョンv2.1.223以降(それ以前はこの実行で出ない)
--permission-prompts none で確認を一律に拒否する必要なバージョンv2.1.259以降
バックグラウンド作業中も running を報告する既定と、0 による切り替え必要なバージョンv2.1.269以降
通知ターンにも「人の入力ではない」旨の注記が付く必要なバージョンv2.1.205以降(それ以前は、idle中に届いた通知に付かない)

バージョンの確認は claude --version で足ります。

たとえば permission_denied が出ないと感じたときは、v2.1.223より前の版で、canUseTool も permissionPromptToolName も指定していない可能性があります。逆に、バックグラウンドのサブエージェントを動かしているのに idle が早く届くなら、v2.1.269より前の版か、CLAUDE_CODE_BG_TASKS_REPORT_RUNNING=0 が効いているかのどちらかです。

使い方別に、どのメッセージで何を判定するか

同じ session_state_changed でも、見る場所は用途で変わります。

用途状態メッセージの使い道完了や結果の判定
自前UIのステータス表示状態メッセージの使い道running でスピナー、idle で入力欄を有効化、requires_action で確認ダイアログへ誘導完了や結果の判定result
セッション一覧の監視状態メッセージの使い道どのセッションが人の応答待ちかを一覧に出す完了や結果の判定不要(状態だけ見る)
自動化の待機制御状態メッセージの使い道次のプロンプトを送る目安にする完了や結果の判定result(idle だけに頼らない)

結果の取得だけが目的なら、状態メッセージは不要です。result を読めば足ります。出力を逐次描画したい場合は、状態ではなくトークン単位のイベントを扱うAgent SDKのストリーミング出力の領分です。ターンがどう回るかの前提はAgent SDKのエージェントループにまとめています。

まとめ

CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 は、running・idle・requires_actionの3値をストリームに足す設定です。SDKなら環境変数だけ、CLIなら --print と stream-json と --verbose が要ります。読むときは、1通を現在の状態として扱い、重複を捨て、完了判定は result に任せる。この3点を守れば、状態メッセージは表示と監視にそのまま使えます。Pythonでは SystemMessage の data から読み、receive_messages() で回します。

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