⛽ 5hブロックが燃え尽きる前に気づく「トークン燃料計」
大学生のころ月10万の副業が、掛け持ちで60万まで伸びた。会社都合の解雇でいったんゼロになった後、Claude Code を使った自律環境を半年かけて組み上げ、いまは月商120万で回っています。その環境の核心は「コードを書くこと」ではなく、崩れる前に気づける仕組みを積み上げることです。今回はその一例、5時間ブロックが燃え尽きる前に警告を出す「トークン燃料計」の全配線を公開します。
なぜこの仕組みが効くのか
Claude Code の5時間ブロックとは
Claude Code(MAX プラン)には、5時間単位でリセットされる利用ブロックがあります。ここで問題になるのが消費量が外から見えないという点です。
ブラウザ版のような残量バーは存在しません。Claude Code のセッションをターミナルで走らせていると、5時間ブロックの上限に近づいても画面には何も変化が起きません。応答が遅くなるわけでもなく、エラーが出るわけでもない。ただ出力品質が静かに、じわじわと劣化していくのです。
私がこれに気づいたのは、ある夜に自動化スクリプトのリファクタを依頼したときでした。同じプロンプトなのに、朝に動かしたときより明らかに出力の密度が薄い。コードの一部が端折られ、エラーハンドリングが抜け落ちていた。後で ccusage で確認すると、そのセッションのアウトプットトークンはすでに 80万を超えていました。閾値 800k の寸前です。
問題は、それを作業中にリアルタイムで知る手段がなかったことです。
「作業で解決する」ではなく「環境で解決する」
真面目な人ほど「もっと注意して使おう」という方向に考えます。でもその発想では仕組みに勝てません。5時間のカウントは無意識のうちに積み上がり、集中しているときほど消費が速い。
私が選んだのは逆のアプローチです。監視を自動化し、状態をステータスラインに常時埋め込む。そうすれば「残量を確認する」という認知コストがゼロになります。見ようとしなくても、チラッと視界に入る場所に数字が出ていれば十分です。
これは燃料計の設計思想と同じです。車を運転するたびにエンジンルームを開けてオイル量を測る人はいません。ダッシュボードにゲージがあるから、ちらりと確認するだけで判断できる。Claude Code の5時間ブロックも、同じ構造を作ればいい。
読者がこの仕組みで得るもの
この記事で作るのは以下の3つです。
token-budget-advisor.sh:ccusageとcost-log.jsonlの二系統からトークン消費・週次コスト・セッション数を集計し、3段階の判定(🟢 ok / 🟡 warn / 🔴 critical)を出力するスクリプト--shortモード: ステータスライン埋め込み専用の1行サマリ出力dashboard.shへの統合: 既存のダッシュボード更新スクリプトから呼ぶことで、毎朝の状況把握に自動で組み込む
完成後は、ターミナルを開くたびにこういった表示がステータスラインに出るようになります。
budget: 🟢 OK (5h:312k tok $1.2 / 7d:$48)
あるいは上限に近づくと自動的に色が変わります。
budget: 🟡 burst (5h:843k tok $3.1 / 7d:$92)
budget: 🔴 cap-near (5h:1231k tok $4.8 / 7d:$134)
これが目に入った瞬間に、「重い作業は次のブロックに回す」という判断が自然にできるようになります。
全体の流れ
データフローの全体像
このシステムは2つのデータソースから情報を拾い、1本のスクリプトに集約します。
┌─────────────────────────────────────────────────────┐
│ データソース層 │
│ │
│ ccusage blocks --json ──────────────────┐ │
│ (アクティブブロックの公式出力トークン数) │ │
│ ├─► token-budget-advisor.sh
│ ~/.claude/logs/cost-log.jsonl ──────────┘ │
│ (session_id × transcript ごとの累積コスト) │
└─────────────────────────────────────────────────────┘
│
┌──────────▼──────────┐
│ 判定エンジン (Python) │
│ │
│ 5h output tokens │
│ ├ ≥ 1,200,000 → 🔴 critical
│ ├ ≥ 800,000 → 🟡 warn
│ └ < 800,000 → 🟢 ok
│ │
│ 7d cost (USD) │
│ └ ≥ $3,000 → 🟡 warn
│ │
│ 直近3日 セッション数 │
│ └ 平均 > 5/day → burst
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ 出力モード │
│ │
│ (引数なし) → JSON詳細 │
│ --short → 1行サマリ │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ dashboard.sh │
│ (日次自動更新) │
│ │
│ ## 💰 Cost (7d) │
│ budget: [--short] │
└─────────────────────┘
なぜデータソースが2系統あるのか
これは実装上の重要な判断です。
ccusage blocks --json は Claude Code の公式CLIツールが出力するアクティブブロックのデータです。現在進行中のブロックのトークン数を最も正確に反映します。ただし ccusage がインストールされていない環境や、ブロックが非アクティブな場合はデータが取れません。
~/.claude/logs/cost-log.jsonl は Claude Code が自動生成するコストログです。セッションIDとトランスクリプトの組み合わせごとに、累積コストと出力トークン数が記録されています。こちらは ccusage に依存しないため、フォールバックとして常に機能します。
スクリプトの実装は次のような優先順位になっています。
# ccusage が居れば 5h block の output token を取る (transcript 計算より公式)
CC_OUTPUT_TOK=""
CC_COST_5H=""
if command -v ccusage >/dev/null 2>&1; then
CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
if [ -n "$CC_JSON" ]; then
EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
active = [b for b in d.get('blocks', []) if b.get('isActive')]
if active:
b = active[0]
tc = b.get('tokenCounts', {}) or {}
out = int(tc.get('outputTokens', 0))
cost = float(b.get('costUSD', 0))
print(f'{out}|{cost}')
else:
print('|')
...
アクティブなブロックだけを取り出し、outputTokens と costUSD をパイプ区切りで受け取ります。その後、cost-log.jsonl の集計結果と合算する際に ccusage の値を優先します(コード127〜131行目)。
# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)
own_out_5h = out_5h
if cc_out is not None and cc_out > 0:
out_5h = cc_out
if cc_cost is not None and cc_cost > 0:
cost_5h = cc_cost
この設計の結果、どちらのデータソースが欠けても動くフォールバック構造になっています。ccusage が使えない場合は cost-log.jsonl 単独で集計が走り、スクリプトは exit 0 で終了します(fail-open 方針)。
cost-log.jsonl の正しい読み方
このファイルには一つ落とし穴があります。同じセッションの行が複数記録される点です。Claude Code はセッション中に逐次的にログを書き込むため、最終的なトークン数が確定する前の途中集計が何行も残ります。単純に全行を合算すると二重カウントが発生します。
これを回避するのが以下の「最終行だけ採用」のロジックです(コード100〜111行目)。
# cost-log は session_id × transcript ごとに累積値で書かれる仕様。
# 最新行のみ採用するため、(session_id, transcript) で最終行を取り直す。
latest = {}
with open(log_path) as f:
for line in f:
try:
r = json.loads(line)
t = datetime.datetime.fromisoformat(r["ts"])
except Exception:
continue
key = (r.get("session_id", ""), r.get("transcript", ""))
prev = latest.get(key)
if (prev is None) or (t > prev[0]):
latest[key] = (t, r)
(session_id, transcript) のペアをキーとして辞書を作り、タイムスタンプが新しい行で上書き続けます。ループが終わった時点で latest に残っているのは各セッション・トランスクリプトの最終確定値だけです。
この集計を通じて取り出された値が、3段階の閾値判定に使われます。
3段階の判定ロジック
閾値は次の4本です(コード139〜142行目)。
THRESH_5H_WARN = 800_000 # output tokens
THRESH_5H_CRIT = 1_200_000
THRESH_WEEK_WARN = 3000 # USD
THRESH_SESS_PER_DAY = 5
5時間ブロックのアウトプットトークンが 80万を超えたら warn、120万を超えたら critical です。週次コストは $3,000 を超えたら warn。さらに直近3日間のセッション平均が 1日5件を超えたら burst フラグが立ちます。
if out_5h >= THRESH_5H_CRIT:
s5 = "critical"
elif out_5h >= THRESH_5H_WARN:
s5 = "warn"
else:
s5 = "ok"
# 集中作業判定: 直近 3 日で平均 > 5 sess/day
recent_days = sorted(by_day.keys())[-3:]
avg_sess = sum(by_day[d] for d in recent_days) / max(1, len(recent_days))
burst = avg_sess > THRESH_SESS_PER_DAY
burst は「燃え尽き予測」としての機能を持ちます。トークンの絶対値がまだ閾値以下でも、セッション頻度が高ければそれだけ消費ペースが速い。今は大丈夫でも今夜中に warn に入る可能性が高い、という予兆シグナルとして機能します。
--short モードと dashboard.sh への組み込み
JSON 詳細出力はデバッグ時に便利ですが、ステータスラインに埋め込むには長すぎます。--short 引数を渡すと1行のサマリが返ります。
result = {
...
"_short": f"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})",
}
アイコンは5時間ステータスを最優先で評価します(コード173〜181行目)。
if s5 == "critical":
icon, label = "🔴", "cap-near"
elif s5 == "warn" or sw == "warn":
icon, label = "🟡", "burst"
elif burst:
icon, label = "🟡", "burst"
else:
icon, label = "🟢", "OK"
--short モードで実行すると次のような出力が得られます。
🟢 OK (5h:312k tok $1.2 / 7d:$48)
この1行を dashboard.sh は次のように取り込んでいます(スクリプト79行目)。
echo "## 💰 Cost (7d)"
~/.claude/scripts/cost-summary.sh --short
echo " budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
dashboard.sh は cron で日次実行され、~/.claude/dashboard.md を自動更新します。つまり 次に dashboard を開いた時点で、昨日の燃料消費サマリが自動で書き込まれています。セッションが重かった翌朝、ダッシュボードを見た瞬間に「昨日はwarnまで行っていた」と分かる構造です。
さらにステータスラインへのリアルタイム組み込みについては次の章で詳しく説明します。
実装の細部——なぜそう書いたのか
fail_open と set -u の組み合わせ
スクリプト冒頭の1行に、全体の設計思想が詰まっています。
set -u # -e は外す: fail-open 方針
-e(エラー時即終了)を外しているのは意図的です。dashboard.sh は79行目で次のようにコマンド置換でスクリプトを呼んでいます。
echo " budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
コマンド置換の中でサブコマンドが exit 1 で終了すると、set -e の環境では呼び出し元のシェルごと死にます。dashboard.sh はcronで毎朝動き、Health・Cost・Hook latencyなど複数セクションをまとめて更新しています。token-budget-advisor.sh がccusageのパス不一致やログ欠如で落ちるたびにdashboard全体が白紙になるのは困る。
だから fail_open() を用意しました。
fail_open() {
if [ "$MODE" = "--short" ]; then
echo "⚫ n/a"
else
printf '{"5h_status":"unknown","weekly_status":"unknown","advice":"%s"}\n' "${1:-no data}"
fi
exit 0
}
[ -f "$LOG" ] || fail_open "cost-log.jsonl not found"
--short モード時は ⚫ n/a を出力して exit 0 で終わります。dashboardの表示は budget: ⚫ n/a になりますが、「データが取れなかった」という状態が文字として残ります。サイレントな白紙より遥かにデバッグしやすい。
ccusage呼び出しの3重防御
if command -v ccusage >/dev/null 2>&1; then
CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
if [ -n "$CC_JSON" ]; then
EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
...
" 2>/dev/null || echo "|")
CC_OUTPUT_TOK="${EXTRACTED%|*}"
CC_COST_5H="${EXTRACTED#*|}"
fi
fi
3層になっています。
1層目: command -v ccusage >/dev/null 2>&1 で存在確認。launchd環境ではPATHが /usr/bin:/bin:/usr/sbin:/sbin しかなく、nvm配下の ccusage は見えません。ここでスキップすれば以降は一切触れません。
2層目: ccusage blocks --json 2>/dev/null || true。ccusageが存在しても何らかのエラー(JSON不正・ネットワーク問題)を吐く場合に備えます。|| true でexit 0を保証し、CC_JSON は空文字になります。
3層目: python3 -c "..." 2>/dev/null || echo "|"。Pythonのパースが失敗しても | というフォールバック文字列を返します。後続の "${EXTRACTED%|*}" と "${EXTRACTED#*|}" というbashのパラメータ展開でパイプ区切りを分割するため、| だけなら両方が空文字になり、ccusage非使用と同じ扱いになります。
python3 -m json.tool などを使わずパラメータ展開で分割しているのはサブシェルを1枚減らすためです。ステータスラインに埋め込まれる場合、呼び出し頻度が高くなる可能性があるため、小さな効率化を積んでいます。
Pythonへの値渡しと isdigit() チェック
bash→Pythonの橋渡しは sys.argv 経由で行います。
RESULT=$(python3 - "$LOG" "${CC_OUTPUT_TOK:-}" "${CC_COST_5H:-}" <<'PY' 2>/dev/null
import sys, json, datetime, collections
log_path, cc_out_str, cc_cost_str = sys.argv[1], sys.argv[2], sys.argv[3]
cc_out = int(cc_out_str) if cc_out_str.isdigit() else None
try:
cc_cost = float(cc_cost_str) if cc_cost_str else None
except ValueError:
cc_cost = None
${CC_OUTPUT_TOK:-} は set -u 環境で未定義変数を空文字に展開するパターンです。ccusageがインストールされていない環境では CC_OUTPUT_TOK が未定義のまま残るため、これがないと unbound variable で落ちます。
cc_out_str.isdigit() は空文字・小数・マイナス値・None文字列のすべてを一発で弾きます。int() に空文字を渡すと ValueError が発生するため try/except が必要になりますが、整数チェックは isdigit() の方が1行で収まる。cc_cost の方は ccusage が "0.001234" のような小数を返すため try/except ValueError で対処しています。
latestディクショナリと「なぜ2パスなのか」
コードを読むと cost-log.jsonl を2回開いています。1パス目と2パス目がある。
# 1パス目
with open(log_path) as f:
for line in f:
...
if t >= cutoff_5h:
pass # ← 実際には何もしない
if t >= cutoff_7d:
day = t.strftime("%Y-%m-%d")
sess_7d_by_day[day].add(sid)
1パス目は現状ほぼデッドコードです。sess_7d_by_day を作っていますが後続で by_day Counter(2パス目で更新)の方が使われています。実装の過程で残ったコードです。
有効なのは2パス目の latest ディクショナリです(コード100〜124行目)。
latest = {}
with open(log_path) as f:
for line in f:
try:
r = json.loads(line)
t = datetime.datetime.fromisoformat(r["ts"])
except Exception:
continue
key = (r.get("session_id", ""), r.get("transcript", ""))
prev = latest.get(key)
if (prev is None) or (t > prev[0]):
latest[key] = (t, r)
for (sid, _tr), (t, r) in latest.items():
out = int(r.get("output", 0))
cost = float(r.get("cost_usd", 0))
if t >= cutoff_5h:
out_5h += out
cost_5h += cost
n_5h += 1
(session_id, transcript) をキーに、タイムスタンプが新しい行で上書き続けます。ループ終了後、latest.items() を走査するのは各セッション・トランスクリプトの最終確定値だけです。
なぜこれが必要か。Claude Code はセッション中に逐次的にJSONLを書き込むからです。同じセッションで同じトランスクリプトが「8,000→18,400→29,700→44,100トークン」と増えるたびに、その時点の累積値として行が追記されます。単純に全行を合算すると8,000+18,400+29,700+44,100=100,200になりますが、正しい消費量は最終値の44,100です。
diff_pct が出力に出ない理由
result 辞書に source_diff_pct フィールドがあります。
diff_pct = None
if cc_out is not None and own_out_5h > 0:
diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)
result = {
...
"source_diff_pct": diff_pct,
"ccusage_used": cc_out is not None,
...
}
--short モードには出ませんが JSON詳細出力に含まれます。ccusage由来のトークン数と cost-log.jsonl 由来のトークン数の乖離をパーセントで記録しています。
これが20%を超え続けているなら、どちらかのデータが壊れているか ccusage のデータ構造が変化したサインです。通常運用では目に触れませんが、「数字がおかしい気がする」と感じたときに token-budget-advisor.sh(引数なし)を手動実行すると JSON 詳細が出て、この値でどちらのソースを疑うべきかがわかります。
adviceの連結と _short の書式
advice フィールドは複数フラグが立った時にすべてを結合します(コード161〜171行目)。
advice_parts = []
if s5 == "critical":
advice_parts.append(f"5h output {out_5h/1000:.0f}k超過: 一旦休憩推奨")
elif s5 == "warn":
advice_parts.append(f"5h output {out_5h/1000:.0f}k接近: 重い作業は次ブロックへ")
if sw == "warn":
advice_parts.append(f"7d cost ${cost_7d:.0f}: MAX定額枠の消費過多")
if burst:
advice_parts.append(f"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中")
if not advice_parts:
advice_parts.append("budget healthy")
「5hがwarnかつweeklyもwarnかつburst」という状態が重なった場合に advice はスラッシュ区切りで3つ並びます。JSON 詳細モードのログを後から grep したとき、複合状態の発生頻度が読めます。
_short の書式は {out_5h/1000:.0f}k tok で千単位に丸めています(コード196行目)。
"_short": f"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})",
:.0f は小数点以下を切り捨てた整数表示です。312000 → 312k と読みやすくなります。コスト表示は5hが小数1桁・週次が整数で、見た目の情報密度を揃えています。
私が詰まった話
詰まり①: dashboardが毎朝白紙になった
最初のバージョンでは set -eo pipefail を入れていました。
ある朝、~/.claude/dashboard.md を開くと中身が空でした。mtimeは今朝のものなのに、ファイルサイズが0バイト。
launchd のジョブは /usr/bin:/bin:/usr/sbin:/sbin しかPATHを持っていません。nvm経由でインストールした ccusage は ~/.nvm/versions/node/v24.13.0/bin/ccusage にあり、launchd環境ではパスが通っていない。ccusage blocks --json が command not found で exit 127 を返し、-e の下でスクリプトが即死しました。
dashboard.sh のコマンド置換 $( token-budget-advisor.sh --short ) がその終了コードを伝播し、リダイレクトブロック全体 {...} > "$OUT" がキャンセルされて OUT は0バイトになりました。
直し方は2段階でした。
# 修正前
set -eo pipefail
...
CC_JSON=$(ccusage blocks --json) # ccusage がなければ exit 127 → 即死
# 修正後
set -u # -e を外す
...
CC_JSON=$(ccusage blocks --json 2>/dev/null || true) # 失敗しても exit 0、CC_JSON は空文字
fail_open() を exit 0 で終わらせるのはこの経験から導いた設計です。budget: ⚫ n/a という1行が dashboard に出る日は今もありますが、それは「ccusageが取れなかった日がある」という意味のある情報であり、白紙よりずっとデバッグしやすい。
詰まり②: 5hで250万トークン使ったという異常値
最初の実装では latest ディクショナリを使わず、全行を単純合算していました。
# 危険な初期実装
with open(log_path) as f:
for line in f:
r = json.loads(line)
t = datetime.datetime.fromisoformat(r["ts"])
if t >= cutoff_5h:
out_5h += int(r.get("output", 0)) # 全行合算
ある夜に重い作業を続けたあと、--short 出力に 🔴 cap-near (5h:2541k tok...) が出ました。120万が閾値なのに250万という数字は物理的におかしい。MAX プランの上限を超えています。
cost-log.jsonl を直接開いて確認すると、同じ session_id のエントリが "output": 11200、"output": 23800、"output": 39500、... と30行以上並んでいました。Claude Code がセッション中に逐次書き込んでいる累積値を、すべて足してしまっていたのです。
(session_id, transcript) でグループ化して最終行だけ採用するように修正したら、同じセッションで 🟡 burst (5h:843k tok...) になりました。正しい数字でした。
この経験で **cost-log.jsonl の output フィールドは「増分」ではなく「累積値」**であることを確認しました。ファイル名から想像できないので、実データで動かしてみて初めて発覚するバグです。
詰まり③: 絵文字が壊れてステータスラインに謎文字列が出た
Python出力に ensure_ascii=False を入れ忘れていました。
# 危険な初期実装
print(json.dumps(result)) # ensure_ascii=False なし
--short モードで出てきたのは次のような文字列でした。
🟢 OK (5h:312k tok $1.2 / 7d:$48)
🟢(U+1F7E2)が 🟢 というサロゲートペアのエスケープになっています。これをターミナルに出力すると、zshの文字列処理次第でそのまま \ud83d という文字として表示されるか、プロンプト幅の計算がずれてカーソル位置がおかしくなるかのどちらかになります。
# 修正後
print(json.dumps(result, ensure_ascii=False))
Python 3のデフォルトは ensure_ascii=True(ASCII外の文字を \uXXXX エスケープする)です。日本語のadvice文字列も同様に壊れます。ensure_ascii=False は絵文字・日本語を扱うJSONシリアライズでは必須の指定です。
詰まり④: isActiveを見ていなかったら前ブロックの分まで積まれた
ccusage blocks --json は次のような構造で返ってきます。
{
"blocks": [
{ "isActive": true, "tokenCounts": { "outputTokens": 412000 }, "costUSD": 1.52 },
{ "isActive": false, "tokenCounts": { "outputTokens": 980000 }, "costUSD": 3.61 },
{ "isActive": false, "tokenCounts": { "outputTokens": 542000 }, "costUSD": 2.01 }
]
}
最初は isActive フィルタリングをしておらず、全ブロックの outputTokens を合算していました。
# 危険な初期実装
d = json.load(sys.stdin)
out = sum(b.get("tokenCounts", {}).get("outputTokens", 0) for b in d.get("blocks", []))
# → 412000 + 980000 + 542000 = 1,934,000 になる
過去ブロックの分まで足してしまい、常にcritical判定になっていました。
修正後はアクティブなブロックだけを取り出します(コード41〜47行目)。
active = [b for b in d.get('blocks', []) if b.get('isActive')]
if active:
b = active[0]
tc = b.get('tokenCounts', {}) or {}
out = int(tc.get('outputTokens', 0))
cost = float(b.get('costUSD', 0))
print(f'{out}|{cost}')
else:
print('|')
tc = b.get('tokenCounts', {}) or {} という二重の {} も見どころです。tokenCounts が null で返ってくる場合(ブロック開始直後など)に、get() は None を返します。None or {} は {} になり、続く .get("outputTokens", 0) でクラッシュしません。get() のデフォルト値だけでは null → None のケースを防げないので、or {} が必要です。
詰まり⑤: set -u 環境で引数なし呼び出しがクラッシュ
--short モードの判定を最初は次のように書いていました。
# 危険な初期実装
if [ "$1" = "--short" ]; then
MODE="--short"
fi
set -u は未定義変数を参照すると即 exit 1 します。引数なしで token-budget-advisor.sh を呼ぶと $1: unbound variable というエラーが出て落ちます。
# 修正後
MODE="${1:-json}"
${1:-json} は $1 が未定義または空文字の場合に json をデフォルト値として使います。引数なし呼び出しは MODE=json、--short 渡しは MODE=--short になり、set -u と共存できます。
その後の判定箇所も [ "$MODE" = "--short" ] に揃えたため、$1 を参照する箇所がスクリプト内から消えました。こういう細かい防御は「本番のcronで突然落ちる」まで気づきにくいバグです。
これらの詰まりのほとんどは「まず動く版を作って、実ファイルで動かしてみる」ことで初めて発覚しました。ユニットテストで閾値ロジックを確認しても、cost-log.jsonl の二重カウント問題は実ファイルを食わせるまで気づけません。launchd のPATH問題も、cron登録して初回実行するまで再現しない。
実データと実環境でしか見えないバグがある。それを「動くはず」で放置しない構造——fail-open、--short の ⚫ n/a、source_diff_pct のデバッグ情報——が積み重なって、今は日々の dashboard が白紙になることなく動いています。
「環境が崩れる前に気づける仕組み」を作るために、自分が詰まった話を先に全部潰しておく。これが月商120万の自律環境を維持するための地味な核心です。
つまずきポイント
前章では5つの詰まりを実コードつきで詳述しました(dashboard白紙・250万トークン異常値・絵文字崩壊・isActiveフィルタなし・set -u引数なしクラッシュ)。ここでは実際に踏んだ追加のつまずきを箇条書きでまとめます。運用に入ってから発覚したものが中心です。
ヒアドキュメントのEOFにシングルクォートを忘れた
メイン集計部は <<'PY' というヒアドキュメントでPythonスクリプトをbashに埋め込んでいます。最初は <<PY(クォートなし)と書いていました。これをやるとbashがドキュメント内の変数を展開してしまいます。たとえばPythonコード内に log_path = sys.argv[1] と書いていても、ヒアドキュメント内に $HOME が出てきた瞬間、bashがそれをホームディレクトリのパスに置き換えます。スクリプトは構文上は動くのに、異なるマシンで動かすとパスがハードコードされてしまう問題が発覚します。<<'PY' のシングルクォートはbashの変数展開を完全に無効にします。bash→Python間の値渡しは sys.argv だけに統一するのがこのスクリプトのルールで、ヒアドキュメント内で変数を使う必要は一切ありません。
1パス目がデッドコードになった経緯
実際のスクリプトを読むと、cost-log.jsonl を2回開いています(80〜96行目の1パス目と、100〜111行目の2パス目)。1パス目の中に次のコメントがあります。
if t >= cutoff_5h:
# transcript 同一の場合は最新行で上書き集計したい → ここは単純合算で OK
# (cost-log は session ごとに累積値で書かれているので、最新行のみ採用すべき)
pass
pass です。何もしていません。最初は1パスで「最終行だけ採用」しようとしたのですが、「この行が最終行かどうか」は次の行を読むまで分かりません。走査中に上書きを続けるには全行を先に読み切ってからでないと「最終」が確定しない。結果として2パス目が必要になり、1パス目はセッション数の sess_7d_by_day だけを集計するコードが残りました。しかし最終的に使われているのは2パス目で更新される by_day Counter の方で、sess_7d_by_day も使われていません。実装の変遷がコードの中に化石として残っています。
タイムゾーンnaiveとawareの衝突が静かに全行スキップを起こす
cost-log.jsonlの ts フィールドが 2026-08-02T05:12:33+00:00 のようなタイムゾーン付き形式だった場合、datetime.datetime.fromisoformat(r["ts"]) はtzawareな datetime を返します。一方、集計基準時刻は次のように計算しています。
now = datetime.datetime.now()
cutoff_5h = now - datetime.timedelta(hours=5)
datetime.now() はtznaiveです。t >= cutoff_5h の比較でtznativeとtzawareが衝突し、Python 3.11未満では TypeError: can't compare offset-naive and offset-aware datetimes が発生します。しかしこのコードは try/except Exception: continue の中に入っているため、例外がコンソールに出ることなく当該行はスキップされます。全行スキップされると out_5h=0 のまま cost-log.jsonl not found ではなく正常値(トークン0)として処理が終わります。🟢 OK (5h:0k tok $0.0 / 7d:$0) という出力になり、「なぜかコストがゼロになっている」という現象として現れる最も気づきにくいバグです。
launchdジョブ名の前提が外れた
dashboard.sh の38行目に次のコードがあります。
launchctl list | grep com.shun | awk '{printf "- %s exit=%s\n", $3, $2}' | head -15
launchdジョブが com.shun.* という命名規則で作られている前提のgrepです。別の命名規則で作ったジョブは一切表示されません。dashboardの「Scheduled Jobs」セクションが1件しか出ない日があり、「ジョブが消えた」と誤解しました。実際は grep パターンがジョブ名に合っていないだけでした。launchctlのリストは Label カラムに正規のジョブ名が出るので、自分の環境のジョブ命名規則と一致するパターンに変更が必要です。
python3 -c 内でシングルクォートが衝突する
ccusage解析部(スクリプト37〜52行目)は printf '%s' "$CC_JSON" | python3 -c "..." という形式です。"..." の中でPythonのシングルクォートを使えるのは、外側がダブルクォートだからです。
EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
import sys, json
d = json.load(sys.stdin)
active = [b for b in d.get('blocks', []) if b.get('isActive')]
...
" 2>/dev/null || echo "|")
d.get('blocks', []) のシングルクォートがbash文字列を終端させません。これはstdinからJSONを渡す方式にしているからです。もし直接 -c 'import sys...' と書いていたら、内部のPythonシングルクォートでbash文字列が終端して構文エラーになります。printf ... | python3 -c "..." と python3 - <<'PY' ... PY の使い分けは、短いスクリプトか長いスクリプトかで決めています。
ステータスライン組み込みでEnterのたびに500ms待たされた
最初、zshの PROMPT に直接コマンド置換を入れました。
PROMPT='%F{blue}%~%f $(~/.claude/scripts/token-budget-advisor.sh --short) %# '
Enterを押すたびにスクリプトが実行されます。Python起動(約80ms)+ cost-log.jsonl の読み込み(行数によるが50〜200ms)+ ccusage呼び出し(200〜400ms)が積み上がり、重い作業中のセッションでは500ms超の待ち時間が体感できました。解決策は dashboard.sh に任せる方式に切り替えることです。dashboard.sh はcronで日次実行されて ~/.claude/dashboard.md を更新します(dashboard.sh 104行目で cat "$OUT")。ステータスラインにはそのキャッシュを読む1行コマンドを入れる方が軽い。あるいはtmuxの status-right に入れて更新間隔を30秒にする方法もあります。
source_diff_pct が20%を超えていて初めて気づいた
通常の --short 出力には出ませんが、引数なしで実行するとJSON詳細に "source_diff_pct": 23.4 のような値が出ます。
diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)
ccusage由来とcost-log.jsonl由来のトークン数の乖離率です。ある日、閾値を超えていないはずなのに体感的に品質が落ちていた。引数なしで手動実行したら source_diff_pct が28.1になっていました。ccusageのデータ構造が前回のアップデートで微妙に変わり、tokenCounts 内のキー名が変わっていたのが原因でした。source_diff_pct がゼロ付近(5%以内)なら両ソースが整合しています。定期的に手動確認する習慣を入れてから、数字のずれを早期発見できるようになりました。
ベストプラクティス
ここまで実装と詰まりを全部解説してきました。動かしてみて「これは最初から知っていれば良かった」と思った実用ルールを15個にまとめます。
1. 監視スクリプトは fail-open で書く
監視が落ちて本体が死ぬのが最悪のパターンです。fail_open() は exit 0 で終わり、--short 時は ⚫ n/a を出力します。dashboard.sh の79行目でコマンド置換に使われているので、advisor が落ちると budget 行が ⚫ n/a になります。白紙より「取れなかった日」という記録の方がデバッグしやすい。エラーを握りつぶすのではなく、「取得失敗」という状態を文字として残すのが fail-open の意義です。
2. set -u だけ使い -e は外す
set -u は未定義変数を即エラーにしてスペルミスを早期発見します。しかし -e を足すと外部コマンドの失敗でスクリプト全体が終了します。cron統合スクリプトでは -e なしが正解です。dashboard.sh も set -uo pipefail を使っていますが(3行目)、advisor.sh は set -u だけです(15行目)。呼び出し元が -uo pipefail でも、呼ばれる側が exit 0 で返せばコマンド置換ブロック全体は生き残ります。
3. データソースを2系統持ち、片方が欠けても動く構造にする
ccusage がない環境でも cost-log.jsonl 単独で集計できる設計にしています。開発機・本番機・launchd のPATH制限された環境でも壊れません。ccusage が取れた場合はそちらを優先します(スクリプト127〜131行目)。単一ソース依存だとインストール状態が変わるたびにスクリプトが死にます。
4. (session_id, transcript) キーで最終行だけ採用する
cost-log.jsonlの output は増分ではなく累積値です。同じセッションが 11200 → 23800 → 39500 → 44100 と増えるたびに行が追記されます。全行合算すると118,600になりますが正しい値は44,100です。latest ディクショナリを2パスで作り(100〜111行目)、最終確定値だけを集計に使うことで正しい数字が出ます。これに気づかないと常にcritical判定になります。
5. isdigit() で整数バリデーションを1行に収める
bash→Python間の値受け渡しでは、空文字・非数値・Noneが混入します。
cc_out = int(cc_out_str) if cc_out_str.isdigit() else None
isdigit() は空文字・小数・マイナス値・None文字列をすべて弾きます。int() に空文字を渡すと ValueError が発生するため try/except が必要になりますが、整数チェックは isdigit() の方が1行で収まります。小数(ccusage の costUSD は "1.524" のような文字列)は float() + try/except で対処します。
6. get() のデフォルト値と or {} を必ず組み合わせる
b.get('tokenCounts', {}) でキーがなければ空辞書を返しますが、キーが存在して値が null の場合は None を返します。get() のデフォルト値だけでは null → None を防げません。
tc = b.get('tokenCounts', {}) or {}
or {} を追加することで None も空辞書に変換されます。ブロック開始直後など tokenCounts が null で返ってくるケースで、続く .get('outputTokens', 0) が AttributeError を吐くのを防ぎます。
7. ensure_ascii=False を絶対に忘れない
Python 3 のデフォルトは ensure_ascii=True です。絵文字の 🟢(U+1F7E2)も日本語のadvice文字列も \uXXXX エスケープになります。ターミナル出力でカーソル位置がずれ、ステータスラインに 🟢 という謎文字列が並びます。絵文字・日本語を含む JSON シリアライズでは json.dumps(result, ensure_ascii=False) が必須の指定です(スクリプト199行目)。
8. ccusage 呼び出しは 3 重防御にする
if command -v ccusage >/dev/null 2>&1; then
CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
if [ -n "$CC_JSON" ]; then
EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "..." 2>/dev/null || echo "|")
存在確認(command -v)→エラー抑制(2>/dev/null || true)→パース失敗フォールバック(|| echo "|")の3層です。launchd環境でPATHが /usr/bin:/bin:/usr/sbin:/sbin しかなく ccusage が見えない場合は1層目でスキップされます。ccusageが存在しても JSON が不正な場合は2層目、Python パースが失敗した場合は3層目がそれぞれ受け止めます。
9. isActive フィルタでアクティブブロックのみを取り出す
ccusage blocks --json は過去ブロックも含む配列を返します。アクティブ1ブロック(41万トークン)+ 過去2ブロック(計152万トークン)の合算が193万になり、常にcritical判定になります。
active = [b for b in d.get('blocks', []) if b.get('isActive')]
アクティブなブロックだけを絞り込んでから取り出します。if active: でアクティブブロックが存在する場合のみ処理し、存在しない場合は print('|') でフォールバックを返します。
10. --short モードと詳細出力を同一スクリプトに統合する
ステータスライン埋め込み用と手動確認用でフォーマットが違っても、スクリプトを分けるとメンテ窓口が2本になります。閾値(800k / 1.2M / $3,000 / 5 sess/day)を変更するとき、片方だけ更新して整合性が崩れます。MODE="${1:-json}" で引数なしをデフォルトJSONとし、--short では _short フィールドだけを取り出します(207〜208行目)。どちらの出力も同じ判定エンジンを通るため、数字の整合は保証されます。
11. source_diff_pct でデータ整合性を常時記録する
--short には出ませんがJSON詳細出力に "source_diff_pct": 4.2 のような値が含まれます。ccusage由来とcost-log.jsonl由来のトークン数の乖離率です。通常は5%以内に収まります。20%を超え続けているなら、ccusage のデータ構造変化かcost-log.jsonlの書き込み形式が変わったサインです。「数字がおかしい気がする」と感じたとき、まず token-budget-advisor.sh(引数なし)を手動実行してこの値を確認します。
12. ヒアドキュメントは <<'EOF' でシングルクォートEOFにする
<<PY にするとbashがヒアドキュメント内の変数を展開します。bash→Python間の値渡しは sys.argv に統一し、ヒアドキュメント内でbash変数を使う必要をなくす。<<'PY' にすれば展開が完全に無効になり、Pythonのリテラル文字列がそのまま届きます。
13. ${VAR:-} で set -u 環境の未定義変数を空文字にする
ccusage がない環境では CC_OUTPUT_TOK が未定義のまま残ります。set -u 下で "$CC_OUTPUT_TOK" を参照すると unbound variable で落ちます。${CC_OUTPUT_TOK:-} は未定義・空文字を両方「空文字」にします。Python側への引数 sys.argv[2] に空文字が届き、cc_out_str.isdigit() が False を返して cc_out = None になります。エラーを握りつぶすのではなく、「データがない」という状態を型安全に伝播させます。
14. ステータスライン埋め込みはキャッシュ経由にする
zshの PROMPT にコマンド置換を直接入れるとEnterごとに実行されます。Python起動80ms + ファイルread + ccusage呼び出し200〜400msが積み上がり、重い作業セッションでは500ms超の待ち時間になります。dashboard.sh がcronで日次実行して ~/.claude/dashboard.md を更新する設計(dashboard.sh 104行目)を活かし、ステータスラインはキャッシュファイルを読む1行コマンドにとどめます。あるいはtmuxの status-right に入れて更新間隔を30秒にする方法も有効です。
15. 閾値は1〜2週間の実データを観測してから決める
THRESH_5H_WARN = 800_000 / THRESH_5H_CRIT = 1_200_000 という数字は最初から決まっていたわけではありません。1〜2週間、token-budget-advisor.sh(引数なし)のJSON詳細出力を手動で確認し、80万トークン超えで出力の密度が体感的に薄くなることを確認してから閾値として採用しました。自分の作業パターンによって最適値は変わります。軽い質問中心なら warn に入る前に5時間ブロックが自然にリセットされることも多い。週次コスト上限 $3,000 も、MAX プラン定額内での使い方に合わせた数字です。まず動かして観測してから数字を決める順番が重要です。
まとめ
token-budget-advisor.sh は200行強のシェルスクリプト+インラインPythonですが、その中に「監視システムを安定稼働させる」ためのほぼすべての設計判断が詰まっています。
最初に作ったバージョンは動きませんでした。set -eo pipefail で毎朝dashboardが白紙になり、全行合算で250万トークンという物理的にありえない数字が出て、絵文字がエスケープ文字列に化けていました。
それらを一つずつ直した結果が今の実装です。fail_open() は dashboard が白紙になった経験から。latest ディクショナリは250万トークン異常値から。ensure_ascii=False は絵文字崩壊から。3重防御の ccusage 呼び出しは isActive フィルタなしの常時critical判定から。${VAR:-} は引数なしクラッシュから。すべての防御が実際に踏んだバグに対応しています。
この種のスクリプトで重要なのは「動いているときの設計」より「崩れたときの動き」です。監視システムが落ちると、監視対象の品質劣化に気づけません。崩れたときに ⚫ n/a の1行が出てくれれば、「今日はデータが取れなかった」という情報として残ります。白紙とは全く違う。
dashboard.sh の79行目に1行追加しただけで、毎朝のダッシュボードに昨日の燃料消費サマリが自動で書き込まれます。
echo " budget: $(~/.claude/scripts/token-budget-advisor.sh --short)"
作業中にトークン残量を確認する認知コストがゼロになりました。気づかないうちに品質が劣化して、翌朝「昨日のコードは怪しいな」と気づく前に、重い作業は次の5時間ブロックに回す判断が自然にできます。
月商120万の自律環境は、派手なAIの機能ではなく、こういう地味な計器の積み重ねで動いています。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!