Claude Code をヘッドレス(claude -p)で何本も起こし、仕事を分担させられるかを技術検証したとき、エージェントの節目ごとに人の承認を挟もうとしました。けれども、承認が下りるまでプロセスを生かしておくと、待っているあいだもメモリを使い続けます。私たちは、PreToolUse フックが返す permissionDecision: "defer" を使って「止めて、承認されたら続きから走る」関門を作れるかを、実機で確かめました。
結論
- フックが
deferを返すと、プロセスは exit 0 で正常に終わり、result のstop_reasonがtool_deferredになりました。is_errorはfalseのままなので、承認待ちかどうかはstop_reasonで見分けます。 --resumeで再開するとフックがもう一度呼ばれ、allowを返すと保留していたツールが実際に実行されました。会話の文脈も保たれていました。denyに添えたpermissionDecisionReasonは、ツールの結果として一字一句そのままモデルに届きました。差し戻しの理由が、そのままエージェントへの指示になります。- MCP ツールの呼び出しも同じように defer できました。再開のときに
allowと一緒にupdatedInputを返すと、ツールを実行するときの入力がその値になりました。
試した環境
- Mac、Claude Code 2.1.170
- 起こし方:
claude -p --output-format stream-json --verbose --include-hook-events --settings <フックの設定> --setting-sources '' --session-id <uuid> --allowedTools Bash
試した仕組みは、エージェントが節目ごとに承認の申請を出し、人が Web の画面から承認・条件付き承認・差し戻しをする作りでした。Web 検索で見つかる解説は互いに食い違うものが多かったので、ここに書いた振る舞いはすべて手元で測りました。
作りたかった関門
条件にしたのは、承認を待つあいだプロセスが残っていないことでした。フックの中で承認が来るまで待つ作りも考えられます。しかし、フックが timeout を超えると取り消されて、止めるはずのツールがそのまま実行されることを、私たちは先に測って確かめていました。そこで、フックはすぐに返すことにし、承認はプロセスの外で待つようにしました。
下の図が、その往復です。フックが defer を返してプロセスをいったん終わらせ、人が承認したら --resume で同じセッションを起こし直します。
フックの台本
検証には、状態ファイルの中身を見て defer・allow・deny のどれかを返すだけの台本を使いました。フックは、イベントの JSON を stdin から受け取り、判定の JSON を stdout に返します。
#!/bin/bash
# 状態ファイルの中身で判定を切り替える PreToolUse フック(検証用)
# pending → defer(プロセスが正常に終わるはず)
# approved → allow(ツールが実行されるはず)
# rejected → deny と理由(理由を読んで方針を変えるかを見る)
set -euo pipefail
STATE_FILE="${SPIKE_STATE:-/tmp/spike-state}"
LOG_FILE="${SPIKE_LOG:-/tmp/spike-hook.log}"
payload="$(cat)"
state="$(cat "$STATE_FILE" 2>/dev/null || echo pending)"
{ echo "--- $(date +%H:%M:%S) state=$state"; echo "$payload"; } >> "$LOG_FILE"
case "$state" in
approved)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"承認されました"}}' ;;
rejected)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"却下します。このコマンドは使わず、代わりに date コマンドで現在時刻を表示してください。"}}' ;;
*)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer","permissionDecisionReason":"承認の申請を出しました。人の承認を待ちます。"}}' ;;
esac
--settings で渡した設定は、これだけです。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/hooks/gate.sh", "timeout": 600 }
]
}
]
}
}
--setting-sources '' も効きました。ふだん人が使っている全体の設定は読まれず、ここで渡したフックだけが走ります。
defer を返すと、正常終了に見える
状態を pending にして起こします。
echo pending > /tmp/spike-state
claude -p "Use the Bash tool exactly once to run: echo hello-from-spike ." \
--output-format stream-json --verbose --include-hook-events \
--settings hooks/settings.json --setting-sources '' \
--session-id "$(uuidgen | tr 'A-Z' 'a-z')" --allowedTools Bash --model sonnet
最後の result は次のとおりでした。プロセスは exit 0 で終わっています。
{
"subtype": "success",
"stop_reason": "tool_deferred",
"is_error": false,
"deferred_tool_use": {
"id": "toolu_...",
"name": "Bash",
"input": { "command": "echo hello-from-spike", "description": "Echo hello-from-spike" }
},
"permission_denials": [],
"result": ""
}
subtype は success、is_error は false、終了コードは 0 です。外から見ると、仕事を終えて正常に止まった回と区別が付きません。終了コードや is_error を見て「失敗していなければ完了」と判定していると、承認待ちを完了と取り違えます。見るべきは stop_reason です。deferred_tool_use には保留したツールの名前と入力が入っているので、承認する人に見せる申請の中身は、ここから組み立てられます。

承認して --resume すると、保留したツールが実行される
状態を approved にして、同じセッションを再開します。
echo approved > /tmp/spike-state
claude -p "保留中の操作を実行し、結果を1行で報告して停止してください。" \
--resume <session-id> --output-format stream-json --verbose \
--include-hook-events --settings hooks/settings.json \
--setting-sources '' --allowedTools Bash --model sonnet
stream-json には、フックが呼ばれて成功し、保留していた echo の結果が返ってくる様子が流れました。
SYSTEM hook_started
SYSTEM hook_response {outcome: success}
USER tool_result hello-from-spike
再開のプロンプトには、元のタスクを書かず、「報告して止まる」という区切りだけを書きました。--resume は1つのプロセスの中で2ターン動きます。渡したプロンプトは、自動で進む続きに足される形で処理されるので、ここに元のタスクを書くと二重になります。
差し戻しの理由は、そのままモデルに届く
状態を rejected にすると、フックは deny と理由を返します。理由の文は、ツールの結果として一字一句そのままモデルに届きました。
USER tool_result 却下します。このコマンドは使わず、代わりに date コマンドで現在時刻を表示してください。
人が画面に書いた差し戻しの理由を permissionDecisionReason に入れれば、それがそのままエージェントへの指示になります。また、--allowedTools Bash で Bash を許可していても、フックの deny のほうが勝ちました。
申請と保留を結びつけるキー
フックが stdin から受け取る入力は、次のとおりでした(値の一部は伏せています)。
{
"session_id": "…",
"transcript_path": "~/.claude/projects/<slug>/<session-id>.jsonl",
"cwd": "<作業ディレクトリ>",
"permission_mode": "default",
"effort": { "level": "high" },
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "...", "description": "..." },
"tool_use_id": "toolu_..."
}
tool_use_id は、result の deferred_tool_use.id と一致しました。フックの側で申請を記録するときにこの値を控えておけば、あとで「どの申請が、どの保留に当たるか」を突き合わせられます。transcript_path と cwd も渡されます。
MCP ツールでも同じ往復ができる
Bash だけでなく、MCP ツールの呼び出しも同じように defer できました。さらに、再開のときにフックが allow と一緒に updatedInput を返すと、ツールを実行するときの入力がその値に置き換わりました。私たちは、承認の中身(承認か、条件付きか、差し戻しの理由は何か)をエージェントに返す経路として、これを使いました。
確かめていないこと
- 1ターンに複数のツール呼び出しがあると、defer は警告付きで無視され、通常の権限の流れになる、と公式に書かれています。私たちは呼び出しを1つに絞って測ったので、再現していません。
- 承認待ちが
cleanupPeriodDays(既定 30 日)を超えるとセッションが消えること、再開のときに MCP が繋がっていないとエラーで終わること。どちらも公式に書かれていることで、私たちは測っていません。 - 申請1件あたりの再開の費用(キャッシュが切れたときの入力トークン)。測ろうとした回に、再開したエージェントが止まらずに動き続けた回が混ざり、測れませんでした。
- 私たちが測ったのは 2.1.170 で、ブラウザを使う起こし方では 2.1.227 でも同じ形を確かめました。それより後の版は確かめていません。公式は、
--bareを将来-pの既定にすると予告しています。--bareは設定ファイルのフックを読まないので、そうなるとこの関門は丸ごと効かなくなります。私たちが Claude Code の版を固定していたのは、このためです。
おわりに
defer と --resume を使って、承認を待つあいだプロセスを残さない関門は作れました。ただし、判定は stop_reason で行うこと、承認はフックの外で待つことの2点を外すと、承認待ちが完了に見えたり、関門が素通りになったりします。
※ この記事に書いた Claude Code の振る舞いは、執筆時点で私たちが測ったものです。いまは変わっている可能性があります。


