前作「macOSのトラップを踏まずにhookスクリプトを書く」でhookの書き方基礎を押さえました。今回は速度の話です。
UserPromptSubmit hookはプロンプトのたびに同期実行されます。hook内で外部コマンドを呼ぶと、その応答待ちがそのままターンのレイテンシになる。私の環境でコスト監視hookを計測したところ、mean 819ms・p95 5320ms・max 5753ms(直近24h・817回)が出ていました。プロンプトを押すたびに最悪5秒以上の待ちが発生していた計算です。
困りごと:ccusageを毎プロンプト呼ぶとひどいことになる
~/.claude/hooks/cost_guard.sh の役割は、activeな5hブロックのoutputトークン量とburn rateを見て、閾値超えを警告することです。そのために ccusage blocks --active --json を毎プロンプト呼んでいました。
ccusageはNode.js製のCLIで起動コストが高く、状況によっては数秒待たされることがある。~/.claude/scripts/hook-latency-wrap.sh で計測・集計した実測値がこれです。
=== hook latency (last 1d, 4086 records) ===
hook n mean p95 max fail
----------------------------------------------------------------------
cost_guard.sh 817 819ms 5320ms 5753ms 0 ⚠
obsidian_context.sh 817 760ms 1386ms 4259ms 0
model_routing_reminder.sh 817 298ms 549ms 2003ms 0
message_display_filter.sh 817 212ms 435ms 1616ms 0
user_prompt_submit.sh 817 272ms 409ms 12295ms 0
cost_guard.sh の p95 5320ms は他の4本を大きく上回っています。60秒以内に状況は大きく変わらないのに、プロンプトのたびにフル実行するのは明らかに過剰でした。
解決:60秒TTLキャッシュ
キャッシュは /tmp/cost_guard_${USER}.cache の1ファイルで完結します。cost_guard.sh の冒頭がこれです。
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
キャッシュファイルが存在し、mtime から 60 秒未満なら中身を stderr に出して即 exit 0。ccusage の呼び出し自体をスキップします。キャッシュに警告文字列が入っていれば再表示され、空ファイルなら何も出ずに抜けます。
設計の肝:平常時も空ファイルを書く
ここが核心です。スクリプト末尾を見てください。
# 警告の有無に関わらず必ずキャッシュを更新する(空でも書く)。
# これが無いと平常時(警告なし)はキャッシュ未作成のまま毎プロンプト ccusage フル実行になる。
# ...
# 警告の有無に関わらず必ずキャッシュを書く(空 WARN なら空ファイル=「平常」を60秒キャッシュ)
printf '%b' "$WARN" > "$CACHE"
[ -s "$CACHE" ] && cat "$CACHE" >&2
exit 0
$WARN が空でも printf '%b' "" > "$CACHE" は実行されるのがポイントです。「警告なし」という事実が空ファイルとしてキャッシュされます。
「警告があった時だけキャッシュを書く」実装にすると何が起きるか。平常時($WARN 空)でキャッシュが作られない → 次のプロンプトでもキャッシュ miss → ccusage フル実行。最もよく起きるパスが最も遅い実行になります。
同じ理由で、ccusage が失敗した場合も空ファイルを書いてから抜けます。
BLOCK_JSON=$(${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json 2>/dev/null) || { : > "$CACHE"; exit 0; }
[ -z "$BLOCK_JSON" ] && { : > "$CACHE"; exit 0; }
|| { : > "$CACHE"; exit 0; } ― 失敗パスでも空ファイルを書いてから抜ける(fail-open)。これがないと失敗するたびにリトライが走ります。
「キャッシュを書くのは結果があった時だけ」は直感的ですが、空の結果=異常なしもキャッシュすべきです。最も多い平常系を一番速くするのがキャッシュの目的なので、例外を記録するより正常を記録する意識が要ります。
fail-open構成:gtimeout 5 でハングを殺す
p95 が 5320ms になっていたのは、ccusage が応答しないまま待ち続けるケースがあったためです。gtimeout 5 で 5 秒を上限にしています。
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; }
${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} はパラメータ展開です。TIMEOUT_BIN が空でなければ "$TIMEOUT_BIN" 5 に展開し、空なら何も展開しない。これで「gtimeout 5 ccusage ...」か「ccusage ...」かを条件分岐なしに切り替えています。
gtimeout が brew でインストールされていない環境は TIMEOUT_BIN="" になり、タイムアウトなしで呼ぶ。hookはfail-openが基本で、コスト監視が遅れるよりClaude Codeが止まるほうが困ります。
踏んだ落とし穴
-
「警告あり時だけキャッシュを書く」にしたら平常時が毎回フル実行 →
printf '%b' "$WARN" > "$CACHE"は$WARNが空でも必ず実行する -
ccusage タイムアウト時もキャッシュを書かないと次のプロンプトでリトライ → 失敗パスでも
{ : > "$CACHE"; exit 0; }で空ファイルを置く -
stat -f %mはmacOS固有 → Linux ではstat -c %Y。hookをLinuxでも使う場合は分岐が要る -
/tmp/のキャッシュはreboot後に消える → 初回フル実行が1回走るだけ。むしろreboot後は古い警告を引き継がないので好都合 - p95が高い=キャッシュが切れた時の正常なフル実行 → mean だけ見ると「全体的に遅い」と誤読する。p95でキャッシュ miss のコストを評価する
まとめ
- UserPromptSubmit hookで外部CLIを毎回呼ぶと、プロンプトのたびに数百〜数千msが積み上がる(実測:mean 819ms・p95 5320ms)
- 60秒TTLのキャッシュファイルを
/tmp/に置くだけで解決できる - 設計の肝は「警告なし(平常時)も空ファイルを書く」こと。書かないと最も多いパスが最も遅い実行になる
- 失敗パスでも空キャッシュを書いてfail-open。hookはClaude Codeを止めてはいけない
-
gtimeout 5でCLIのハングを上限5秒でkill。${TIMEOUT_BIN:+...}の展開で条件分岐なしにオプショナルなプレフィックスを実現
次回は、hookのstderrに出した警告をClaude Codeがどう扱うか ―― UserPromptSubmit・PreToolUse・PostToolUseのhookプロトコルの全体像を整理します。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!