UserPromptSubmit hookの外部コマンドを60秒キャッシュで高速化する ― cost_guard.shの設計
前回、macOSでhookスクリプトが刺さる罠を潰した話を書きました。今回はその隣にある問題 ―― UserPromptSubmit hookが遅い、具体的には cost_guard.sh というhookが1ターンあたり数百msから数秒かけていた件です。
困りごと:hookは同期実行、しかも毎プロンプト
UserPromptSubmit hookはプロンプトを送るたびに同期実行されます。Claude Codeはこのhookの完了を待ってから次に進むので、hookが重ければそのままターン全体のレイテンシに乗ります。
~/.claude/logs/hook-latency.jsonl を集計するとそれが数字で出ます。hook-latency-report.sh の直近24hレポート(today-brief-20260731.md に日次で貼られているもの)はこうでした。
=== hook latency (last 1d, 5753 records) ===
hook n mean p95 max fail
----------------------------------------------------------------------
skills-auto-update.sh 3 45652ms 46308ms 46308ms 0 ⚠
cost_guard.sh 1150 843ms 5348ms 5753ms 0 ⚠
obsidian_context.sh 1150 735ms 1487ms 4259ms 0
model_routing_reminder.sh 1150 275ms 548ms 2003ms 0
user_prompt_submit.sh 1150 278ms 423ms 12295ms 0
message_display_filter.sh 1150 192ms 418ms 1616ms 0
skills-auto-update.sh は低頻度バッチなので別枠として、常時発火する6本の中で cost_guard.sh がmean 843ms、p95 5348ms、max 5753msとダントツで重い。1150回中fail 0なので落ちてはいないが、体感の「Enterから応答が来るまで」に毎回800ms〜数秒がまるごと乗っている計算です。
計測の仕組み自体はシンプルで、settings.json の各hookは直接呼ばれず hook-latency-wrap.sh を経由します。
# hook-latency-wrap.sh 抜粋
"$HOOK_BIN" "$@"
exit_code=$?
end_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
elapsed_ms=$(( (end_us - start_us) / 1000 ))
printf '{"ts":"%s","hook":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
"$ts" "$hook_name" "$elapsed_ms" "$exit_code" >> "$JSONL"
python3 を起動せずbash builtinの EPOCHREALTIME で開始・終了を取るだけなので、計測自体のオーバーヘッドはほぼゼロです。だから上の843ms/5348msは素直に cost_guard.sh 本体の重さです。
原因:外部コマンド ccusage を毎回フル実行していた
cost_guard.sh はUserPromptSubmit hookとして、activeな5時間ブロックのoutput tokenとburn rateを見て、枠を食い潰しそうなら警告を出す役目です。中身はこう。
CCUSAGE=$(command -v ccusage 2>/dev/null || echo "$HOME/.nvm/versions/node/v24.13.0/bin/ccusage")
[ -x "$CCUSAGE" ] || exit 0
BLOCK_JSON=$(${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json 2>/dev/null) || { : > "$CACHE"; exit 0; }
ccusage はNode製の外部CLIで、blocks --active --json はそれなりに重い集計処理です。これをプロンプトを打つたびに毎回同期で起動していたのが843ms〜5348msの正体でした。直前のccusage呼び出しから何秒経っていようが、プロンプトの内容とは無関係に常にフル実行される作りだったからです。
対策:60秒TTLのキャッシュファイル
cost_guard.sh の対処は、/tmp に置いたキャッシュファイルのmtimeで60秒以内かどうかを判定し、範囲内ならccusageを呼ばずキャッシュの中身をそのまま出すことです。
CACHE="/tmp/cost_guard_${USER}.cache"
CACHE_AGE=60 # 秒
if [ -f "$CACHE" ]; then
age=$(( $(date +%s) - $(stat -f %m "$CACHE" 2>/dev/null || echo 0) ))
[ "$age" -lt "$CACHE_AGE" ] && { cat "$CACHE" >&2 2>/dev/null; exit 0; }
fi
これだけなら普通のTTLキャッシュですが、この設計の肝は警告が無い平常時にも空ファイルを書くことです。
# どの終了パスでも必ずキャッシュを更新する(空でも書く)。
# これが無いと平常時(警告なし)はキャッシュ未作成のまま毎プロンプト ccusage フル実行になる。
...
# 警告の有無に関わらず必ずキャッシュを書く(空 WARN なら空ファイル=「平常」を60秒キャッシュ)
printf '%b' "$WARN" > "$CACHE"
[ -s "$CACHE" ] && cat "$CACHE" >&2
これがコメントの通りで、「警告があるときだけキャッシュに書く」実装にすると、ほとんどの時間を占める「警告なしの平常時」がキャッシュファイル未生成のまま毎回スキップ判定に失敗し、結局ccusageをフル実行し続けます。空文字列であっても「60秒以内はこの結果でよい」という事実そのものをファイルの存在=mtimeで表現する必要があります。
「キャッシュを書くのは結果があった時だけ」は直感的に見えますが、空の結果=異常なし、もキャッシュすべきです。最も多く通る平常系を一番速くするのがキャッシュの目的なので、例外を記録するより正常を記録する意識のほうが要ります。
fail-open構成:ハングしても黙って抜ける
もう一つの実コードのポイントは、ccusageが遅い・壊れている・存在しない場合の扱いです。cost_guard.sh は冒頭のコメントに設計方針を明記しています。
# 仕様:
# - MAX定額なので $ 自体は気にしない。output token と burn rate を見る
# - 閾値超えたら STDERR に警告(block しない・他 hook を妨げない)
# - ccusage が無い/壊れていても silent fail(fail-open)
# - キャッシュ: 60秒以内の連続実行はキャッシュ結果を使う(hook 自身のコスト最小化)
これを実現しているのが gtimeout 5 によるハングkillです。
TIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
BLOCK_JSON=$(${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json 2>/dev/null) || { : > "$CACHE"; exit 0; }
[ -z "$BLOCK_JSON" ] && { : > "$CACHE"; exit 0; }
${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} はパラメータ展開で、TIMEOUT_BIN が空でなければ "$TIMEOUT_BIN" 5 に展開し、空なら何も展開しません。これで「gtimeout 5 ccusage ...」か「ccusage ...」かを条件分岐なしに切り替えています。
ccusageが5秒応答しなければ gtimeout がkillし、非ゼロ終了として扱われて即 : > "$CACHE"(空キャッシュ書き込み)+ exit 0 に落ちます。ここでも「空でもキャッシュを書く」原則が効いていて、ハング側のパスも次の60秒はスキップされます。jq でactive blockが取れない・JSONが空といった異常系も同様に空キャッシュ+exit 0で抜けるので、UserPromptSubmit全体をブロックすることはありません。
p95 5348ms はこのタイムアウト境界(5秒+α)に張り付いた値そのものです。fail-openのexit 0自体は速くても、gtimeout 5が実際にkillするまでの5秒は待つので、ccusageが詰まった回のレイテンシはキャッシュがあっても最初の1回は必ず乗ります。60秒キャッシュは「詰まりを消す」対策ではなく「詰まりの再発頻度を1/60に減らす」対策だと理解しておく必要があります。
踏んだ落とし穴
- 警告時だけキャッシュを書く実装だと平常時が救われない → 空WARNでも必ずキャッシュファイルを書き、mtimeで「60秒以内は平常」を表現する
ccusage blocks --active --jsonは素の状態だと数百ms〜数秒かかる → 毎ターン同期実行は避け、TTLキャッシュを挟む- 外部コマンドはハングしうる前提で書く →
gtimeout 5を必ず被せ、非ゼロ終了・空JSON・jqパース失敗のどれでもexit 0で抜けるfail-open構成にする stat -f %mはmacOS固有 → Linuxならstat -c %Y。他OSでも使う想定なら分岐が要る- p95が高い=キャッシュmissの正常なフル実行 → meanだけ見ると「全体的に遅い」と誤読する。p95でキャッシュ切れのコストを評価する
- 計測しないと「なんとなく遅い」で終わる →
hook-latency-wrap.shで全hookを同じフォーマットのJSONLに落とし、hook-latency-report.shでp95/maxを可視化してから手を入れる
まとめ
- UserPromptSubmit hookは同期実行のため、外部コマンドを毎回叩くとターンあたりのレイテンシに直結する(実測:
cost_guard.shはmean 843ms・p95 5348ms) - 対策は60秒TTLのキャッシュファイルだが、肝は**「警告ゼロの平常時も空ファイルを書く」**こと。ここを省くと平常時に毎回フル実行が再発する
- 加えて
gtimeout 5でccusageのハングをkillし、どの異常系でもexit 0で抜けるfail-open構成にする - 手を入れる前に
hook-latency-wrap.sh/hook-latency-report.shでp95・maxを計測する。「重い気がする」を数値にしてから直す
次回は、この hook-latency.jsonl を含むログ群が肥大化して ~/.claude が7GBを超えた話 ―― ディスク監査とクリーンアップの仕組みを書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!