SessionEnd hookの後始末を終了・/clear・/resumeで自動化する
SessionEnd hookはセッションが終わる直前に呼ばれ、終了理由別のmatcherで後始末を分けられます。決定不可・既定1.5秒というタイムアウト制約の中での書き方を示します。
SessionEnd hookは、セッションの終了・/clear・インタラクティブな/resumeで呼ばれます。終了理由ごとに後始末を分けられますが、セッションの終了そのものは止められません。既定のタイムアウトは1.5秒です。
SessionEnd hookはいつ、何のために呼ばれるか
SessionEnd hookは、Claude Codeのセッションが終了するタイミングで呼ばれるフックです。用途はクリーンアップ処理、セッション統計のログ記録、セッション状態の保存の3つです。セッションが終わる合図そのものだと考えると使い道が見えやすくなります。
呼ばれるのはセッションの終了時だけではありません。/clearによるクリア、インタラクティブな/resumeでのセッション切り替えでも発火します。どの操作で終了したかは入力JSONのreasonフィールドに入り、値は次の5つです。
| reason | 意味 |
|---|---|
clear | 意味/clearコマンドでセッションをクリアした |
resume | 意味インタラクティブな/resumeで別セッションへ切り替えた |
logout | 意味ユーザーがログアウトした |
prompt_input_exit | 意味プロンプト入力欄が表示された状態で終了した |
other | 意味上記に当てはまらないその他の終了 |
bypass_permissions_disabledという値も表には出てきますが、v2.1.234で廃止されており、Claude Codeはもう送ってきません。古い設定を流用したmatcherに残っていたら削除します。
入力JSONはsession_id・transcript_path・cwd・hook_event_nameといった共通フィールドにreasonが加わった形です。Claude Code v2.1.257以降であればscratchpad_dir(セッションの作業用一時ディレクトリ)も届くので、セッション中に貯まった一時ファイルをここから辿って削除する後始末も書けます。
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "SessionEnd",
"reason": "other"
}終了理由ごとにmatcherで振り分ける
SessionEnd hookのmatcherは、このreasonフィールドの値でフィルタします。matcherの評価規則は他のフックと共通です。英数字・アンダースコア・ハイフン・スペース・カンマ・パイプだけで書いたmatcherは完全一致(または|・,区切りの複数完全一致)として扱われ、それ以外の文字を含むとJavaScriptの正規表現として評価されます。
reasonの値はアルファベットとアンダースコアだけなので、clearやresume|logoutのように書けばそのまま完全一致で動きます。カンマ区切り(resume, logoutのような空白付きの書き方を含む)を使うにはClaude Code v2.1.191以降が必要です。
matcherを空にする、または省略すると全ての終了理由で発火します。理由ごとに処理を分けたいときだけ、次のように複数のmatcherグループを並べます。
{
"hooks": {
"SessionEnd": [
{
"matcher": "clear|resume",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/session-cleanup.sh"
}
]
},
{
"matcher": "logout|other",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/session-log.sh",
"timeout": 5
}
]
}
]
}
}同じreasonに複数のグループが一致した場合は、全て並列に実行されます。上の例のようにreasonが重ならない2グループなら、1回の終了で動くのはどちらか一方です。
設定例 — ログ書き出しとクリーンアップスクリプト
設定は.claude/settings.json(プロジェクト共有)・~/.claude/settings.json(自分の全プロジェクト)・.claude/settings.local.json(gitignore対象)のいずれかに書きます。スキルやサブエージェントのfrontmatterに直接書くこともできます。
上の設定例が呼ぶsession-log.shの中身です。標準入力で受け取ったJSONからreason・session_id・cwdを取り出し、1行のログに追記します。
#!/bin/bash
# .claude/hooks/session-log.sh
input=$(cat)
reason=$(jq -r '.reason' <<<"$input")
session_id=$(jq -r '.session_id' <<<"$input")
cwd=$(jq -r '.cwd' <<<"$input")
echo "$(date -Iseconds) session=$session_id reason=$reason cwd=$cwd" >> ~/.claude/session-history.logClaude Codeを起動せずに、このスクリプト単体へサンプルのJSONを流し込んで動作を確かめられます。HOMEを捨てても構わないディレクトリに差し替えておけば、本物のログを汚しません。
mkdir -p /tmp/demo/.claude
echo '{"session_id":"abc123","cwd":"/work/demo","hook_event_name":"SessionEnd","reason":"clear"}' \
| HOME=/tmp/demo ./session-log.sh
cat /tmp/demo/.claude/session-history.log手元(macOS、jq入り)では終了コード0で、次の1行が追記されました。
2026-09-30T15:10:11+09:00 session=abc123 reason=clear cwd=/work/demojqを3回呼ぶこの構成でも、手元の環境では実行が0.1秒前後でした(環境によって変わります)。既定の1.5秒には収まりますが、外部コマンドを増やすほど余裕は減るので、重い処理はここに入れない設計が前提になります。ネットワークに出る処理や大きなファイルの走査を足すなら、次に述べるタイムアウトの扱いを先に決めておきます。
コマンドは${CLAUDE_PROJECT_DIR}で参照しています。シェル形式のcommandでは、プレースホルダーを二重引用符で囲みます。プロジェクトのパスに空白が含まれていても壊れません。作業ディレクトリが途中でworktreeに変わっても、このプレースホルダーはセッションが始まったプロジェクトルートを指し続けるので、hookスクリプトの置き場所が動きません。現在の作業ディレクトリを知りたいときは、パスではなく入力JSONのcwdフィールドを読みます。
どの設定ファイルに書くか — スコープの選び方
同じSessionEnd hookでも、どのファイルに書くかで効く範囲が変わります。
| 定義場所 | 有効範囲 | 共有 |
|---|---|---|
~/.claude/settings.json | 有効範囲自分の全プロジェクト | 共有不可(自分のマシンのみ) |
.claude/settings.json | 有効範囲単一プロジェクト | 共有可(リポジトリにコミット) |
.claude/settings.local.json | 有効範囲単一プロジェクト | 共有不可(gitignore対象) |
| 組織のmanaged policy settings | 有効範囲組織全体 | 共有可(管理者制御) |
プラグインのhooks/hooks.json | 有効範囲プラグイン有効時 | 共有可(プラグインに同梱) |
チームで統一したい後始末(ログアウト時のキャッシュ削除など)は.claude/settings.jsonに書いてコミットし、自分専用の統計収集は~/.claude/settings.jsonに置く、という使い分けが基本です。ここで1点注意があります。Claude Code on the webのクラウドセッションはローカルの~/.claude/settings.jsonを読みません。クラウド側でも同じ後始末を効かせたいなら、リポジトリにコミットする.claude/settings.json側に定義を寄せます。
設定は複数のファイルにまたがっていても上書きではなくマージされます。ユーザー・プロジェクト・ローカルの各設定は互いのSessionEnd hookを消さずに追加し合う仕組みなので、個人用とチーム共有用を安心して併用できます。
登録したSessionEnd hookがどのファイルから読み込まれているかは、Claude Code内で/hooksと入力すると確認できます。イベントごとの登録数とmatcher、handlerの中身を読み取り専用で見られます。一時的に止めたいときは設定ファイルからエントリを削除するか、"disableAllHooks": trueを設定します。ただしこれは全hookをまとめて止める設定で、SessionEnd hookだけを個別に無効化する仕組みはありません。組織の管理設定で配られたhookも、ユーザー・プロジェクト・ローカルのdisableAllHooksでは止まらず、管理設定側のdisableAllHooksでしか止められません。
1回の起動だけ止めるなら、--settings '{"disableAllHooks": true}'をつけて起動する方法があります。プロジェクトやローカルの設定より優先されるため、ファイルを書き換えずに済みます。フック・LSP・auto-memoryなどをまとめて外す--bare(v2.1.285のclaude --helpで確認)も、settingsとプラグインのhookを読み込みません。反対に、hookがなぜ動かないのかを調べたいときは--debug-file <path>でデバッグログをファイルに書き出せます。作業ディレクトリが消えていたときのフォールバック警告も、ここに残ります。
SessionEnd hookが抱える制約とよくあるつまずき
SessionEnd hookは他の多くのフックと違い、決定権を持ちません。できるのは後始末だけで、終了そのものは止められません。
SessionEndで効かない2つの返し方
JSON出力
systemMessageを含む出力フィールドは、返しても全て捨てられます。exit code 2
止まりません。ユーザーにstderrが表示されるだけで、終了処理はそのまま進みます。
タイムアウトも独特です。SessionEnd hookには既定で1.5秒という共有の時間予算しかありません。これはセッション終了・/clear・インタラクティブな/resumeのいずれにも適用されます。予算を広げる方法は2つあり、効き方が少し違います。
1.5秒に収まらない後始末を通す手順
- 1
個別のhookに timeout を書く
予算は設定ファイル内で最大の
timeoutまで自動で引き上げられます(上限60秒)。ただしtimeoutを書いていないhookは既定のままです。プラグイン提供のhookに書いたtimeoutは予算を引き上げません。 - 2
環境変数で予算そのものを上書きする
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSにミリ秒で指定します。この値はtimeoutを持たない各hookのタイムアウトにもなります。 - 3
v2.1.267 以前かどうかを確かめる
v2.1.268より前は、環境変数が広げるのは全体の予算だけでした。
timeoutのないhookは、予算を広げても1.5秒で打ち切られました。古い環境では、環境変数だけでなく個別にtimeoutを書きます。
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=5000 claudeもう一つ見落としやすいのが、作業ディレクトリが消えているケースです。hookはClaude Codeの現在の作業ディレクトリで実行されますが、worktreeや一時ディレクトリが別プロセスによって削除済みだと、Claude Codeはセッション開始時のディレクトリ→プロジェクトルート→ホームディレクトリ→システムの一時ディレクトリの順にフォールバック先を探します。使ったフォールバック先はデバッグログに警告として残ります。
複数のSessionEnd hookが同じreasonに一致した場合、それらは並列に実行されます。同じhandlerをプロジェクト設定とローカル設定の両方に重複して書いても、実行は1回にまとめられるのでログが二重に記録される心配はありません。ただしプラグインやスキルが定義した同じ内容のhandlerは別扱いになり、両方が実行されます。
hookプロセスは呼び出し元の環境変数をそのまま引き継ぎますが、OTEL_*系のエクスポーター変数はClaude Codeが全てのサブプロセスから取り除きます。テレメトリの送信先URLなどを環境変数経由でSessionEnd hookに渡す設計にするときは、除去される変数に依存しないようにします。
終了に関わるフックのうち、SessionEndだけができないこと
セッションの終わりに近いフックは3つあり、exit code 2の効き方が違います。
exit code 2を返したときの効果
Stop / PreCompact
Stopは「Claudeが応答を終えるとき」に呼ばれ、exit code 2で終了を止めて会話を続けさせます。PreCompactはコンパクション(会話履歴の圧縮)の直前に呼ばれ、exit code 2でコンパクションを止めます。
SessionEnd
stderrをユーザーに見せるだけで、終了処理は進みます。ターンの途中で介入する制御点ではなく、終わったあとに副作用を残すための場所です。
エラーで終わったターンの扱いはStopFailure hookでAPIエラー終了時のフォールバックを書くで扱っています。StopFailureも出力と終了コードが無視されるイベントです。セッションの立ち上がり側にあるSetupやInstructionsLoadedのようなイベントについてはClaude Code Setup/InstructionsLoadedフックの実務設定にまとめています。タスクの最後に一時ファイルを消すだけなら、プロンプトに一文加える方法もあります。手順はClaude Codeに一時ファイルを片付けさせるプロンプトの書き方にまとめています。
hookをシェルコマンドではなくHTTPエンドポイントで受けたい場合、SessionEndを含む多くのイベントはtype: "http"でも定義できます。ただしSessionEndは出力を破棄するため、HTTP側で返す決定フィールドに意味はありません。副作用としてリクエストを送るだけの用途に限られます。HTTPで受ける設定の詳細はClaude CodeのHooksをHTTPエンドポイントで受けるで解説しています。
matcherの評価規則(完全一致か正規表現か)はイベントをまたいで共通の仕組みです。ツール呼び出し系のイベントでの具体的な書き方はPreToolUse hookでツール実行前に許可・拒否・改変するが参考になります。
まとめ
最初に決めるのは、後始末が1.5秒に収まるかどうかです。収まるならreasonごとのmatcherだけで足ります。収まらないなら、timeoutを書くか環境変数で予算を広げます。まずはログ1行を書き出す構成から始め、スクリプト単体の実行時間を測ってから処理を足していくと、打ち切りに気づかないまま運用する事態を避けられます。