⏱ promptのたびにccusageを叩くな——60秒キャッシュで作るコスト番兵 — リーダー×

⏱ promptのたびにccusageを叩くな——60秒キャッシュで作るコスト番兵

#automation#claudecode#副業2026-09-03 · 約30

大学生のころ月10万だった収入が、掛け持ちで60万まで伸びた。会社都合で解雇されてゼロに戻り、半年かけてClaude Code自律環境を一から組み上げた。今は月商120万。その環境の中核にあるのが「hookで作る自律的なコスト管理」だ。

なぜこの仕組みが効くのか

Claude CodeのMAXプランは月額固定だが、無制限ではない。5時間ブロック単位のoutput token上限があり、使い切ると次のブロックまで重い作業が止まる。1時間あたりのburn rateが跳ね上がっているときはOpusを連投しているサインで、軽い作業ならHaiku 4.5に切り替えるだけでブロック末期まで余裕が生まれる。

問題は「気づくのが遅い」ことだ。500kトークンを超えたあたりで初めて重さを感じ、800kに達して「あ、もう末期だ」と悟る。このサイクルを毎日繰り返していた。

解決策として最初に思いつくのは「UserPromptSubmitフックでccusageを叩いて現在地を確認する」だ。プロンプトを投げるたびにブロック状況を見せてくれれば、早めに手を打てる。実際にそう実装したが、すぐに別の問題が浮かんだ。

hookそのものがボトルネックになる。

ccusageはAPIを叩きにいく外部コマンドだ。1回200〜400msかかる。プロンプトを連打するような作業フロー——「ちょっと直して」「次はここ」「もう少し短く」——では、このレイテンシがそのままUXの劣化になる。hook自体がコストを生み、体感速度を落とし、本末転倒になる。

「毎回叩く」と「叩かない」の間に第三の選択肢がある。60秒以内の再実行はキャッシュ結果を返す、という設計だ。

これがcost_guard.shの核心にある考え方だ。コスト状況は1分で劇的には変わらない。1分前に「平常」だったなら、今も平常とみなして問題ない。逆に800kトークン超えの警告が出たなら、それも60秒は有効だ。キャッシュを挟むことで、連打時のccusage呼び出しを事実上1分に1回に抑えられる。

この発想は「作業効率を上げる自動化が、自動化のコストで相殺される」という構造的な罠への直接の回答だ。hookを仕込むほどに環境が賢くなる半面、hook自体が重くなれば作業の流れが止まる。軽量・fail-openを原則にしないと、自律化が自縄自縛になる。

全体の流れ

2つのhookが役割を分担している。

UserPromptSubmit(プロンプト投入ごと)
  └── cost_guard.sh
        ├── キャッシュ確認(/tmp/cost_guard_$USER.cache)
        │     60秒以内 → キャッシュをSTDERRへ出力して即exit
        │     60秒超   → ccusageを実行
        ├── ccusage blocks --active --json(gtimeout 5秒)
        │     失敗/タイムアウト → 空キャッシュを書いてexit 0(fail-open)
        └── 閾値判定 → 警告をキャッシュに書いて出力

Stop(セッション終了時)
  └── stop_cost_log.sh
        ├── transcript.jsonlを読んで全メッセージのusageを集計
        ├── model別レートで実コストをUSD計算
        └── ~/.claude/logs/cost-log.jsonlにJSONL追記

リアルタイムの「今どこにいるか」をcost_guard.shが担い、セッション終了後の正確な記録をstop_cost_log.shが担う。前者はcpusageのブロック状況(推定値)、後者はtranscriptから計算した実績値だ。二つを使い分けることで「速報と確報」が揃う。

cost_guard.sh: キャッシュ設計の実コード

ファイルの冒頭12〜17行がキャッシュ判定の全体だ。

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

stat -f %mでファイルの最終更新タイムスタンプ(Unix秒)を取得し、現在時刻との差を計算する。60秒以内ならキャッシュをSTDERRに吐いてexit 0。シンプルだが重要な点が二つある。

一つ目は、stat -f %m2>/dev/null || echo 0という保険だ。Linuxではstat -fが動かない(GNU statの書式は-c %Y)。このスクリプトはmacOS向けだが、将来Linuxに持ち込んでもsilent failするよう逃げ道を作っている。echo 0を返せばage計算が$(date +%s)になり、必ずCACHE_AGEを超えてキャッシュヒットしない——安全側に倒れる。

二つ目は、キャッシュが存在しないとき(=初回実行時)だけccusageを呼ぶ構造になっていること。ファイルがなければ[ -f "$CACHE" ]が偽になり、そのままccusage実行フローへ進む。

次の19〜27行がccusage呼び出しとfail-open処理だ。

CCUSAGE=$(command -v ccusage 2>/dev/null || echo "$HOME/.nvm/versions/node/v24.13.0/bin/ccusage")
[ -x "$CCUSAGE" ] || exit 0

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; }

command -v ccusageが失敗したときのフォールバックとして、nvmのパスを直書きしている。これは「PATHが通っていない素のshell環境でhookが呼ばれる」というClaude Code特有の罠への対処だ。hookはインタラクティブシェルではなく、PATHが通常より短い環境で実行される。

gtimeout(macOSでHomebrewが提供するGNU timeout)でccusageの最大実行時間を5秒に抑えている。ccusageが応答しない・ネットワーク断・APIレート制限——いずれの場合も5秒で切り、空キャッシュ(: > "$CACHE")を書いてexit 0する。空キャッシュを書く意味が重要で、「正常終了したが警告なし」という状態をキャッシュに刻むことで、次の60秒間は何も起きなかったことにできる。空キャッシュを書かないと、次のプロンプトでまたccusageが走る。

TIMEOUT_BINが空(gtimeoutが見つからない)のときは${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}が空文字になり、タイムアウトなしでccusageを実行する。これもfail-open——あるなら使う、ないなら素で実行、どちらでも動くようにする。

閾値判定と「平常時もキャッシュを書く」設計

45〜57行の閾値判定を見ると、もう一つの設計意図が見える。

WARN=""

if [ "${OUT_TOK%.*}" -ge 800000 ] 2>/dev/null; then
  WARN="${WARN}🚨 [cost-guard] output ${OUT_TOK} tok — 5h block 末期の可能性。新規重作業はやめておく\n"
elif [ "${OUT_TOK%.*}" -ge 500000 ] 2>/dev/null; then
  WARN="${WARN}⚠ [cost-guard] output ${OUT_TOK} tok — block 半分超過\n"
fi

BURN_INT=$(printf '%.0f' "$BURN_HR" 2>/dev/null)
if [ "${BURN_INT:-0}" -ge 80 ] 2>/dev/null; then
  WARN="${WARN}⚠ [cost-guard] burn rate \$${BURN_INT}/hr (API equiv) — Opus 連投。軽い作業は Haiku 4.5 (claude --model haiku) 推奨\n"
fi

printf '%b' "$WARN" > "$CACHE"
[ -s "$CACHE" ] && cat "$CACHE" >&2

output tokenの閾値は500k(ブロック半分超過)と800k(末期)の2段構えだ。burn rateは$80/hr以上でHaiku推奨の警告が出る。

printf '%b' "$WARN" > "$CACHE"警告の有無にかかわらずキャッシュを書くWARNが空でも空ファイルを書く。これがなければ、平常時(警告なし)はキャッシュが更新されず、毎プロンプトccusageが走り続ける。

${OUT_TOK%.*}はbashの文字列操作で小数点以下を切り落とす。-ge(integer比較)に浮動小数を渡すとエラーになるため、jqが"500000.0"のような文字列を返したときへの防衛だ。末尾の2>/dev/nullでこの比較自体が失敗してもエラー出力を黙殺し、スクリプト全体は止まらない。

stop_cost_log.sh: transcript.jsonlから実コストを算出

セッション終了時に動くStop hookは設計思想が正反対だ。速さより正確さが優先される。

PRICING = {
    "claude-opus-4-7":   {"input": 15.0, "output": 75.0, "cache_read": 1.5,  "cache_create_5m": 18.75, "cache_create_1h": 30.0},
    "claude-opus-4-6":   {"input": 15.0, "output": 75.0, "cache_read": 1.5,  "cache_create_5m": 18.75, "cache_create_1h": 30.0},
    "claude-sonnet-4-6": {"input": 3.0,  "output": 15.0, "cache_read": 0.3,  "cache_create_5m": 3.75,  "cache_create_1h": 6.0},
    "claude-haiku-4-5":  {"input": 1.0,  "output": 5.0,  "cache_read": 0.1,  "cache_create_5m": 1.25,  "cache_create_1h": 2.0},
}

料金テーブルはモデルごとに持ち、prefix matchで引く。transcript.jsonlには複数モデルが混在しうる(OpusとSonnetを使い分けた日など)ため、1メッセージごとにrate_for(model)で正しいレートを当てる。

キャッシュ料金の計算が精緻で、cache_creation_input_tokens(全体)と内訳のephemeral_5m_input_tokensephemeral_1h_input_tokensを両方チェックする。内訳がゼロの場合は全体を5分キャッシュ扱いにするフォールバックも入っている。

if cc_5m + cc_1h == 0 and cc_total > 0:
    cc_5m = cc_total

出力はJSONL形式で~/.claude/logs/cost-log.jsonlに追記される。1セッション1行。こちらはccusageのリアルタイム推定値ではなく、transcriptから計算した実績値なので、課金レポートのソースとして信頼できる。

実装の詳細

jqでactiveブロックだけを取り出す

キャッシュとfail-openを抜けた先、31〜37行でjqによるJSON解析がある。

ACTIVE=$(echo "$BLOCK_JSON" | jq '.blocks[] | select(.isActive == true)' 2>/dev/null)
[ -z "$ACTIVE" ] && { : > "$CACHE"; exit 0; }

OUT_TOK=$(echo "$ACTIVE" | jq -r '.tokenCounts.outputTokens // 0')
COST=$(echo "$ACTIVE"   | jq -r '.costUSD // 0')
BURN_HR=$(echo "$ACTIVE" | jq -r '.burnRate.costPerHour // 0')
ENTRIES=$(echo "$ACTIVE" | jq -r '.entries // 0')

select(.isActive == true)は複数ブロックが返ってきても今走っているブロックだけに絞る。MAXプランでは原則1アクティブブロックだが、コマンド発行のタイミングによって直前のブロックが混入することが稀にある。filterなしだと前ブロックの大きな消費量を拾って誤警告が出る。

// 0はjqのalternative operator。.burnRate.costPerHourがnullでも0を返し、以降の計算が壊れない。

2>/dev/nullはjq自体が失敗したとき(jq未インストール、壊れたJSONなど)のエラー出力を黙殺する。ACTIVEが空になり次のガード[ -z "$ACTIVE" ]で自然に抜ける。このガードが**「空キャッシュを書いてexit 0」**になっているのが重要で、jq失敗でも次のプロンプトまでの60秒間は静かになる。

${OUT_TOK%.*}という一行の意味

閾値比較の直前に必ずこの形が出てくる。

if [ "${OUT_TOK%.*}" -ge 800000 ] 2>/dev/null; then

bashの-geは整数比較しか受け付けない。しかしjqはoutputTokensをJSONのnumber型で返すため、"500000.0"のような浮動小数点文字列になることがある。-geに小数点付き文字列を渡すとエラーになる。${OUT_TOK%.*}は末尾の.以降をすべて削る文字列展開で、"500000.0""500000"に変換する。

さらに末尾に2>/dev/nullを付けてある。これは比較式自体のエラーを黙殺する保険だ。OUT_TOKが空文字、nullN/Aのような異常値でも、if分岐全体が黙って偽になりスクリプトが続く。エラーを出して止まるより、警告を出さずに続く方が正解だ。hookが止まるとClaude Codeのプロンプト投入がブロックされる。

${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}という展開

27行目のccusage呼び出しはこうなっている。

BLOCK_JSON=$(${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json 2>/dev/null) || { : > "$CACHE"; exit 0; }

${var:+word}はbashのパラメータ展開で「varが非空ならwordに展開、空なら消える」という動作をする。TIMEOUT_BIN/opt/homebrew/bin/gtimeoutなら"$TIMEOUT_BIN" 5が展開されてgtimeout 5 ccusage ...になる。TIMEOUT_BINが空なら何も挿入されずccusage ...だけになる。タイムアウトあり・なし両方に対応しながらコードが一行で収まる。

この展開は25行目でセットされている。

TIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""

command -vで見つからなければHomebrewの固定パスを試し、それも実行不可ならTIMEOUT_BIN=""にリセットする。「見つからなければ空にする」という明示的なフォールバックがないと、TIMEOUT_BINに非実行可能なパス文字列が残り、展開したときに/path/that/doesnt/exist 5 ccusageという壊れたコマンドになる。

stop_cost_log.sh:PythonをBashで包む構造

Stopフックはbashでは書かれていない。実態はbashがinlineのPythonを起動する構造だ。

INPUT=$(cat)
[ -z "$INPUT" ] && exit 0

export STOP_INPUT="$INPUT"
export COST_LOG_PATH="$COST_LOG"
export DEBUG_LOG_PATH="$DEBUG_LOG"

python3 - <<'PY'
import os, sys, json, datetime
data = json.loads(os.environ.get("STOP_INPUT", ""))

catでstdinを丸ごと読み、envに乗せてからheredocでPythonを起動する。なぜ直接パイプしないのか。python3 - <<'PY'の形ではPythonはstdinからコードを読むため、stdinにデータを渡せない。envを経由することでheredocとデータを両立させている。

export STOP_INPUT="$INPUT"は長いJSONをenv変数に詰める。Claude Codeのhookが渡してくるJSONはsession_idtranscript_pathcwdhook_event_nameなどを含む1行。サイズとしては問題ない。

transcript.jsonlの読み込みにはerrors="replace"が付いている。

with open(tp, "r", encoding="utf-8", errors="replace") as f:

transcriptは長いセッションで数十MBになることがある。途中に壊れたUTF-8バイト列が混入してもerrors="replace"U+FFFDに置換して読み続ける。errors="strict"(デフォルト)だとUnicodeDecodeErrorで落ちてコスト記録がゼロになる。実際にこれで1週間分のログが消えた経験があるので後述する。

Pythonのコスト計算ループが扱う複雑さ

1メッセージあたりの計算は単純に見えて、キャッシュ料金の扱いで細かい分岐がある。

cc_total = usage.get("cache_creation_input_tokens", 0) or 0
cc_5m = (usage.get("cache_creation", {}) or {}).get("ephemeral_5m_input_tokens", 0) or 0
cc_1h = (usage.get("cache_creation", {}) or {}).get("ephemeral_1h_input_tokens", 0) or 0
if cc_5m + cc_1h == 0 and cc_total > 0:
    cc_5m = cc_total

AnthropicのAPIは2026年時点でキャッシュ作成トークンを2段階で返す。フィールドcache_creation_input_tokensが合計、ネストしたcache_creation.ephemeral_5m_input_tokensephemeral_1h_input_tokensが内訳だ。古いバージョンのSDKやモデルによっては内訳がなく合計しか返さない。内訳ゼロで合計がある場合は全量を5分キャッシュ(単価18.75$/MTok)として扱うフォールバックが入っている。

(usage.get("cache_creation", {}) or {})という書き方は、cache_creationキーが存在するがnullの場合(None)をor {}で空辞書に変換するためだ。None.get(...)はAttributeErrorになる。


私が詰まった話

一番最初の失敗:PATHが通っていなかった

hookを仕込んで最初のプロンプトを投げたとき、何も起きなかった。エラーも出ない。原因を追うのに30分かかった。

Claude Codeのhookはログインシェルでもインタラクティブシェルでもなく、一種のサブプロセスとして起動される。~/.zshrcで設定しているnvm経由のPATHが引き継がれない。ターミナルでwhich ccusageと打てばフルパスが返るが、hookの実行環境ではcommand -v ccusageが空になる。

これに対応したのが19行目のフォールバックだ。

CCUSAGE=$(command -v ccusage 2>/dev/null || echo "$HOME/.nvm/versions/node/v24.13.0/bin/ccusage")
[ -x "$CCUSAGE" ] || exit 0

command -vが失敗した場合、nvmの既知のパスを直書きする。これでhookが動くようになった。ただしnvmのバージョン番号は環境依存なので、別マシンや将来のアップグレードで再び壊れる。スクリプトに直書きする値は定期的に確認する必要がある。

gtimeoutが見つからないとスクリプトが止まる

macOSにはtimeoutコマンドがない。GNU coreutilsを入れるとgtimeoutが使えるが、Homebrewのインストール先がM1とIntelで違う。/usr/local/bin/gtimeout/opt/homebrew/bin/gtimeoutが混在する。

最初はTIMEOUT_BIN="/opt/homebrew/bin/gtimeout"と決め打ちしていた。ほかのマシンでhookを試したときにIntelの/usr/local/bin/にしかなく、タイムアウトなしでccusageが走り続けた。ネットワーク障害時に5秒どころか40秒blockした。

現在のコードはcommand -v gtimeoutで探して、なければHomebrewのパスを試し、それも実行不可なら空にする。タイムアウトなしで実行するのは理想ではないが、hookが全く動かないよりはましだという判断だ。失敗したらキャッシュを書いてexit 0するので最悪40秒止まっても次のプロンプトは通る。

平常時にキャッシュを書かなかった

初期実装では警告が出たときだけキャッシュを書いていた。

# 初期版(バグあり)
if [ -n "$WARN" ]; then
  printf '%b' "$WARN" > "$CACHE"
  cat "$CACHE" >&2
fi

警告なし(平常時)はキャッシュを更新しない。結果として、閾値を下回っている間は毎プロンプトccusageが実行される。60秒のスロットリングがまったく効かない。

発覚したのは連打作業中にターミナルのレスポンスが明らかに遅い日が続いたことだ。念のためtime cost_guard.shを打ったら300〜400msかかっていた。キャッシュが機能していない疑いが浮かび、ls -la /tmp/cost_guard_*.cacheでタイムスタンプを見ると、平常時に更新されていないことがわかった。

修正は1行追加だった。

# 修正版:警告の有無に関わらず必ず書く
printf '%b' "$WARN" > "$CACHE"
[ -s "$CACHE" ] && cat "$CACHE" >&2

printf '%b' "$WARN" > "$CACHE"WARNが空でも空ファイルを作る。[ -s "$CACHE" ](ファイルサイズが0より大きい)が偽になり出力はされないが、タイムスタンプだけが更新される。このタイムスタンプが次回のstat -f %mでヒットし、60秒間のキャッシュが効く。

stat -fがLinuxで動かなかった

しばらくはこれが問題になっていなかったが、CI環境(Ubuntu)でhookをテストしようとしたときに壊れた。

age=$(( $(date +%s) - $(stat -f %m "$CACHE" 2>/dev/null || echo 0) ))

LinuxのGNU statでは-fは「ファイルシステム情報を表示」というフラグで、フォーマット指定子は-cだ。stat -f %mを打つとLinuxでは全く違う出力になるか、エラーになる。2>/dev/null || echo 0で0が返り、age計算は$(date +%s) - 0になる。現在時刻のUnix秒(例:1750000000)から0を引いた値は明らかにCACHE_AGE(60秒)を超えるため、キャッシュヒットしない。

実害としては「Linux環境ではキャッシュが効かずccusageが毎回走る」になる。エラーは出ない。静かに劣化する。

対応は2>/dev/null || echo 0をそのまま残す方針にした。「失敗したら0を返してキャッシュを無効化する」が安全方向へのフォールバックになっているからだ。Linux対応が必要ならunameで分岐するか、python3 -c "import os; print(int(os.path.getmtime('$CACHE')))"に置き換える選択肢もある。現時点ではmacOS専用として割り切っている。

set -u が仕込んだ地雷

cost_guard.shの1行目にset -uがある。

set -u

未定義変数の参照でexit 1になる設定だ。これがTIMEOUT_BIN=""の処理と組み合わさって予期しない問題を起こした。

初期実装ではTIMEOUT_BINを最初から定義せず、条件分岐の結果として代入していた。gtimeoutが見つかる環境では代入される、見つからない環境では変数が未定義のままだった。${TIMEOUT_BIN:+...}の展開で未定義変数を参照するとset -uが発動してexit 1し、しかも|| { : > "$CACHE"; exit 0; }のfail-open処理も動かずキャッシュが書かれないまま終了する。

次のプロンプトで同じことが起き、ccusageが走ってまた失敗し、永遠にキャッシュが作られない状態になった。

修正は明示的な空初期化を入れることだ。

TIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""   # ← ここで必ず定義済みにする

[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""で「実行不可なら空文字を代入」して変数を確実に定義済みにする。以降は${TIMEOUT_BIN:+...}が安全に展開できる。

stop_cost_log.shがset -uo pipefailset -uより厳しい設定になっているのも同じ考えだ。ただしStop hookは即時性より正確性が求められ、コマンドが失敗したら記録を諦めるという方向性なので、pipefailが足を引っ張ることは少ない。

transcript.jsonlのUTF-8エラーでコスト記録が消えた

stop_cost_log.shを入れた最初の週、cost-log.jsonlがほぼ空のまま増えなかった。デバッグログ(~/.claude/logs/stop_cost_log.log)を有効にするためにCC_COST_DEBUG=1をセットして動かすと、read_error: 'utf-8' codec can't decode byte 0xe2 in position ...というエラーが記録されていた。

原因はあるセッションのtranscriptにUTF-8として不正なバイトが混入していたことだ。コード補完の途中で強制終了したセッションで壊れた可能性が高い。そのtranscriptの読み込みで例外が上がり、except Exception as e: log(f"read_error: {e}"); sys.exit(0)でそのままexitしていた。つまり何千トークンも使ったセッションのコストが記録されずに終わった。

修正はopen時のerrorsモードを変えることだ。

# 修正前
with open(tp, "r", encoding="utf-8") as f:

# 修正後
with open(tp, "r", encoding="utf-8", errors="replace") as f:

errors="replace"にすると不正なバイト列をU+FFFD(?)に置換して読み続ける。壊れた行のusageが若干おかしくなることはあっても、セッション全体が欠落するよりはるかにましだ。この修正を入れてから記録の欠落は発生していない。

burn rateの「$/hr」をAPI等価コストと誤解されていた

これは実装のバグではなく、警告文の誤解だ。

WARN="${WARN}⚠ [cost-guard] burn rate \$${BURN_INT}/hr (API equiv) — Opus 連投。"

MAXプランのccusageが算出するburn rateは「もしAPIを直接叩いていたら時間当たりいくらになるか」という換算値であって、実際にその金額が請求されるわけではない。最初この警告を見て「MAXプランなのになぜ時間あたり$100以上かかっているんだ」とパニックになった。

(API equiv)という注記はその混乱を防ぐために後から入れた。Opus連投で$80/hrという数字は実感として正しい目安で、これが出ているときにHaiku 4.5(claude --model haiku)に切り替えると、次のブロックまでの余裕が体感できるほど変わる。警告の意味さえ正しく理解していれば、この数字は実際に有用だ。

つまずきポイント

前段で7つの落とし穴を解剖したが、実際の運用では10を超えるつまずきを経験している。前段で詳述していない項目を中心に、箇条書きで網羅する。

① chmod +x を忘れてhookが無音で無視される hookファイルを作成した直後に実行権限を付けなかった。~/.claude/hooks/cost_guard.shが存在しても、-rwxr-xr-xになっていないとClaude Codeはエラーを出さずにhookを呼ばない。ls -la ~/.claude/hooks/*.shで必ず確認する。dotfileのセットアップスクリプトにchmod +x ~/.claude/hooks/*.shを入れておくと新マシン移行時の詰まりを防げる。

② jqが未インストールで全処理がスキップされる macOSのデフォルトにはjqは入っていない。brew install jqが済んでいない環境ではACTIVE=$(echo "$BLOCK_JSON" | jq ... 2>/dev/null)が空になり、[ -z "$ACTIVE" ] && { : > "$CACHE"; exit 0; }のガードでそのまま終了する。警告もエラーも出ない静かな失敗だ。hookを仕込んだ直後にcommand -v jqで確認する。

③ 複数のClaude Codeセッションが同じキャッシュを共有する CACHE="/tmp/cost_guard_${USER}.cache"はマシン上の全Claude Codeセッションで共有される。2つのターミナルでClaude Codeを並列起動しているとき、片方がccusageを呼んだタイミングでもう片方の60秒タイマーがリセットされる。コスト情報自体はセッション横断で共有されてよいので実害はほぼないが、セッション固有化が必要な場合は/tmp/cost_guard_${USER}_$PPID.cacheにする。

④ hookがexit 1で終わるとプロンプトがブロックされる set -u環境で未定義変数を踏むとexit 1になる。Claude Codeはhookの失敗(exit 1)を検知してプロンプト受付を止める仕様だ。cost_guard.shがどのパスを通ってもexit 0で終わるよう徹底されている理由はここだ。|| { : > "$CACHE"; exit 0; }という形が随所に入っているのは、警告もコスト計測も諦めてプロンプトを通すという明示的な優先順位だ。

⑤ ccusageのバージョン変更でJSONフィールドが変わって無音で止まった ccusageは外部ツールであり、メジャーバージョンアップでJSONのフィールド名が変わることがある。.blocks[].tokenCounts.outputTokensが変わるとOUT_TOKが空になり、整数比較で閾値を超えないまま警告が出なくなる。月1回はccusage blocks --active --json | jq '.'で生のJSONを確認し、スクリプトのjqパスが実フィールドと一致しているか目視する。

⑥ MAXプラン以外でburn rateが常に0になる ccusage blocks --active --jsonが返すburnRate.costPerHourはMAXプランの5時間ブロック消費を前提とした換算値だ。API課金プランでは意味のある値が返らず、BURN_HRが常に0になりOpus連投警告が機能しない。スクリプトのヘッダーに「MAX定額なので $ 自体は気にしない」と明記されているのはそのためだ。MAXプラン専用として割り切って使う。

⑦ cost-log.jsonlの行数が数ヶ月で肥大化する 1セッション1行のJSONL形式は1日10セッションで年間3600行を超える。行数自体は小さいが、jq -sで全件集計するスクリプトを後から書くと数万行になってから速度が問題になった。月次でcost-log-2026-09.jsonlのようにファイルを分割するか、SQLiteに移す判断は早い段階でしておく。

⑧ CC_COST_DEBUG=1を設定したまま放置してdebug logが肥大化した stop_cost_log.shのデバッグログはCC_COST_DEBUG=1をセットすると~/.claude/logs/stop_cost_log.logに書き続ける。デバッグ後にunsetを忘れると、セッションが終わるたびに行が追記される。echo $CC_COST_DEBUGで確認し、不要ならunset CC_COST_DEBUGを明示的に実行する。

⑨ nvmのバージョン番号をハードコードしたまま半年放置した 19行目の"$HOME/.nvm/versions/node/v24.13.0/bin/ccusage"はnodejsのバージョン番号を直書きしている。nvm installで新バージョンに切り替えるとこのパスが存在しなくなり、[ -x "$CCUSAGE" ] || exit 0でfail-openになって無音で止まる。nvmアップグレード後に「hookが動いていない」と気づくのに時間がかかった経験がある。nvm useを変えたタイミングでls ~/.nvm/versions/node/を確認してスクリプトの該当行を更新する。

⑩ STDERRの出力がIDEのターミナルでは見えない cat "$CACHE" >&2はSTDERRに警告を出力する。Claude CodeのCLIではhookのSTDERRがプロンプトの直上に表示されるが、VS CodeやJetBrainsのExtension経由では別パネルに流れるか、抑制されることがある。「hookを入れたが警告が出ない」と思ったら、まずターミナルで直接bash ~/.claude/hooks/cost_guard.sh < /dev/nullを実行してSTDERRが見えるか確認する。

⑪ 閾値の500k/800kがMAXプランの仕様変更で陳腐化する 500000(ブロック半分超過)・800000(末期)という数字はコードに直書きされている。Anthropicは5時間ブロックのoutput token上限を変更することがあり、その場合は手動でスクリプトを更新しなければ警告タイミングがずれる。COST_GUARD_WARN_HIGH="${COST_GUARD_WARN_HIGH:-800000}"のように環境変数で上書きできる形にしておくと保守が楽になる。


ベストプラクティス

実装と多数の失敗から蒸留した原則を14個にまとめた。

1. hookのデフォルトはfail-open(exit 0)にする hookが失敗してプロンプトがブロックされるのは最悪のシナリオだ。外部コマンド未インストール・ネットワーク断・JSON解析失敗——すべてのエラーパスでexit 0に着地するよう設計する。警告が欠落してもClaude Codeが止まるよりはるかにましだ。cost_guard.shはccusage未インストール・gtimeout未インストール・jq失敗・タイムアウトのすべてでexit 0する。

2. 外部コマンドには必ずタイムアウトを付ける ${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --jsonのように、5秒の上限を設ける。ネットワーク断やAPIレート制限でccusageが応答しないとき、タイムアウトなしなら40秒以上blockする。gtimeoutが見つからない場合は上限なしで実行するが、それも|| { : > "$CACHE"; exit 0; }で着地させる。

3. 平常時(警告なし)もキャッシュを書く

printf '%b' "$WARN" > "$CACHE"   # WARN が空でも実行
[ -s "$CACHE" ] && cat "$CACHE" >&2

WARNが空でも> "$CACHE"でファイルのタイムスタンプを更新する。これがなければコスト閾値を下回っている間は毎プロンプトccusageが走り続ける。「平常を記録する」ことがキャッシュの核心だ。

4. コマンドの探索はdiscovery → hardcodeのフォールバック順で書く hookはインタラクティブシェルではなく、~/.zshrcのPATH設定が効かない環境で動く。command -v ccusageが失敗してもnvmの既知パスを試すフォールバックを必ず入れる。それも実行不可ならexit 0で逃げる。この3段構えでPATH依存の詰まりを防ぐ。

5. set -uを使うなら変数を必ず明示的に初期化する TIMEOUT_BIN=""のように空文字を代入して変数を定義済みにする。条件分岐の結果として生成される変数を初期化しないと、set -u下では${TIMEOUT_BIN:+...}展開でもexit 1になる。[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""という「実行不可なら空にする」パターンが安全だ。

6. jqのalternative operator // 0で数値フィールドのnullを防ぐ

OUT_TOK=$(echo "$ACTIVE" | jq -r '.tokenCounts.outputTokens // 0')

JSONフィールドが存在しない・nullの場合でも// 0で0を返す。nullを整数比較に渡すとエラーになる。すべての数値取得行に// 0をつけておけば後段の比較が安全になる。

7. 浮動小数点を整数比較に渡す前に%.*で切る jqはnumber型を"500000.0"のような文字列で返すことがある。${OUT_TOK%.*}でドット以降を削除してからbashの-geに渡す。さらに比較式の末尾に2>/dev/nullを付けて、OUT_TOKが空・null・N/Aでもエラーを黙殺してifが偽になるよう保険をかける。

8. PythonをBashで包むときはenvを経由してデータを渡す python3 - <<'PY'のheredocではstdinがコードの読み込みに使われ、データをstdinで渡せない。export STOP_INPUT="$INPUT"でenvに載せ、Python側はos.environ.get("STOP_INPUT")で受け取る。stop_cost_log.shのexport STOP_INPUT / COST_LOG_PATH / DEBUG_LOG_PATHは全部この理由だ。

9. ファイルを読む際はerrors="replace"でUTF-8エラーを吸収する

with open(tp, "r", encoding="utf-8", errors="replace") as f:

長期稼働のtranscriptは強制終了や途中書き込みで不正なバイト列を含むことがある。デフォルトのerrors="strict"ではUnicodeDecodeErrorが上がり、exceptでexit 0になってセッション全体の記録がゼロになる。errors="replace"で不正バイトをU+FFFDに置換して読み続ける方が実用的だ。

10. キャッシュ料金は内訳フィールドと合計フィールドの両方をチェックする

cc_5m = (usage.get("cache_creation", {}) or {}).get("ephemeral_5m_input_tokens", 0) or 0
cc_1h = (usage.get("cache_creation", {}) or {}).get("ephemeral_1h_input_tokens", 0) or 0
if cc_5m + cc_1h == 0 and cc_total > 0:
    cc_5m = cc_total  # 内訳なしは全量を5分キャッシュ扱い

AnthropicのAPIはcache_creation_input_tokens(合計)とそのネスト内訳(ephemeral_5m/ephemeral_1h)を両方返す。古いSDKやモデルでは内訳がなく合計だけのこともある。両方チェックしてフォールバックを持たせないと、過去セッションのコスト計算が狂う。

11. リアルタイム推定(速報)と実績記録(確報)を使い分ける cost_guard.shが出すburn rateはccusageの推定値(API換算)だ。「今いくら使っているか」という感覚値として使う。正確なコストが必要なら~/.claude/logs/cost-log.jsonljq -s '[.[].cost_usd] | add'で集計する。2つのデータソースの性質を混同しない。

12. burn rateの単位を警告文に必ず明記する

WARN="⚠ [cost-guard] burn rate \$${BURN_INT}/hr (API equiv) — Opus 連投"

(API equiv)の注記がなければ、MAXプラン利用者が「時間$100の請求がくる」と誤解する。MAXプランとAPI課金プランの違いを警告文自体で伝える。コメントはコードに書いても読まれないが、警告文の括弧書きは毎回目に入る。

13. 閾値と秒数は環境変数で上書きできるようにしておく CACHE_AGE="${COST_GUARD_CACHE_AGE:-60}"のように書いておけば、MAXプランの仕様変更や個人の感覚に合わせて再コンパイルなしで調整できる。30秒にしたいセッションと120秒で良いセッションを使い分ける運用が可能になる。

14. DEBUGフラグはstopフック内部で必ずenv経由にし、外部から制御する stop_cost_log.shはCC_COST_DEBUG=1を外から渡すとdebug logを有効化する。フラグをスクリプト内部にハードコードするのではなく、envで制御する設計にしておくことで「デバッグのたびにファイルを編集する」という手間を省き、かつ本番環境でのデバッグログ漏れも防ぐ。


まとめ

cost_guard.shの設計を一言で言えば「速さと正確さのトレードオフを60秒というキャッシュウィンドウで解決した」ものだ。

毎プロンプトccusageを叩けば常に最新状態がわかるが、300〜400msの遅延がUXを壊す。叩かなければ警告が来ない。60秒以内の再実行はキャッシュを返し、60秒を超えたときだけ外部コマンドを呼ぶ——この第三の選択肢が、hookの自律性とパフォーマンスを両立させる。

stat -f %mでタイムスタンプを読む1行・${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}でタイムアウトを条件付き挿入する1行・printf '%b' "$WARN" > "$CACHE"で平常時もキャッシュを更新する1行。どれも一見地味だが、どれが欠けても全体が壊れる。hookを読むときはその1行1行に「どのエラーシナリオへの対処か」を問いながら読むと、設計の意図が見える。

stop_cost_log.shはこの設計の反対側に立つ。速さは不要で、正確さだけが求められる。transcript.jsonlを丸ごと読んでモデル別のAPIレートで計算し、JSONLに追記する。速報と確報、両方が揃ってはじめて「今どこにいるか」と「月に何を使ったか」が可視化される。

この仕組みを組んでから、5時間ブロックを末期まで使い切って作業が止まることがほぼなくなった。Opus連投の警告が出たタイミングでHaiku 4.5(claude --model haiku)に切り替えると、次のブロックまでの余裕が体感できるほど変わる。月商120万の半分はOpus連投をやめて軽い作業をHaikuに寄せた段階で達成した——それくらいモデル選択がスループットに効く。

重いhookを安全に組み込む汎用パターンはこうだ。「外部コマンドにタイムアウトをつける・失敗は空キャッシュを書いてexit 0・平常時もキャッシュを書く」。この3原則を守れば、どんな外部コマンドでもhookに安全に組み込める。コスト番兵以外にも、Slack通知・Gitのブランチ状態確認・テスト結果の自動チェックなど、同じパターンが使い回せる。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!