🔍 transcript.jsonlで「トークン浪費Skill」を特定する
月商120万の自律環境を回していても、どのSkillが毎週静かにトークンを食い続けているかは、誰も教えてくれませんでした。
Claude Codeの /usage を叩くと、出てくるのは claude-sonnet-4-6: ¥3,240 というモデル別の合計額だけです。「先週より2割高い」はわかる。「どのSkillが原因か」はわからない。そのギャップを埋めるのが usage-breakdown.sh です。transcript.jsonlをPythonでパースし、Skill・Agent・MCPサーバーごとの呼び出し回数を Counter で集計する、106行のシェルスクリプトです。
この記事では、そのスクリプトの仕組みと動かし方を実コードと実数字で解説します。
なぜこの仕組みが効くのか
Claude Codeが記録しているものの正体
Claude Codeはセッション中の全操作を ~/.claude/projects/ 以下の .jsonl ファイルへ逐次書き出しています。1行1イベントのJSONL形式で、1セッション=1ファイルです。ファイル名は <project-id>/ ディレクトリ以下に並びます。
1レコードの骨格はこうなっています。
{
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"name": "Skill",
"input": {
"skill": "pre-completion-self-audit"
}
}
]
}
}
message.content[] の中に "type": "tool_use" ブロックが並ぶ。name フィールドが呼び出されたツール名です。Bashツール、Editツール、Skillツール、Agentツール、MCP呼び出し——すべてこの形式で記録されています。
ここに気づいたとき、「これをCounterで集計すれば丸見えだ」と思いました。Skillツールの場合は input.skill にスキル名が入っており、Agentツールなら input.subagent_type、MCPサーバーなら mcp__<server>__<tool> というツール名の命名規則から __ 分割でサーバー名を抽出できます。構造が一貫しているので、パーサーは驚くほどシンプルに書けます。
/usage が教えてくれないこと
Claude Codeの /usage コマンドが出力するのは、期間内のモデル別コスト集計です。
Model Cost
claude-sonnet-4-6 ¥3,240
claude-opus-4-8 ¥ 892
これはこれで有用ですが、コストの内訳が見えない。どのセッションで、何のSkillが、何回呼ばれてトークンを使ったのかがわかりません。
usage-breakdown.sh はトークン量ではなく呼び出し回数を集計します。トークン数の正確な集計にはAPIレスポンスの usage オブジェクトを拾う必要がありますが(スクリプト内コメント: token 数は usage オブジェクト集計が必要だが、まずは call count で代替)、呼び出し回数だけでも「何が多いか」の輪郭は十分に見えます。100回呼ばれているSkillと1回しか呼ばれていないSkillでは、トークン消費のオーダーが違います。
mtimeウィンドウで「直近N日」に絞る
全セッション分を集計すると古いログが混入して比較がぶれます。スクリプトはファイルの mtime で時間窓を切ります。
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
デフォルトは 7d、引数で 30d や 14d に変えられます。--short フラグを渡すと statusline 向けの1行サマリだけ出ます。
5015 tool_use across 39 sessions (7d)
これをmacOSのステータスバーウィジェットに流せば、週をまたいで積み上がっていく総コール数が常時見えます。
Counterで集計する4本の軸
スクリプトが集計するのは4本のカウンターです。
skill_calls = collections.Counter() # Skillツール → input.skill
agent_calls = collections.Counter() # Agentツール → input.subagent_type
mcp_calls = collections.Counter() # mcp__<server>__* → サーバー名
plugin_skill_calls = collections.Counter() # plugin:skill 形式のnamespace
tool_calls が全ツールのカウンターで、上記4本はその内訳です。Skillの中でも plugin:skill-name 形式のものはnamespaceごとに束ねる——この粒度が実際に役立ちます。superpowers:brainstorming と superpowers:research を別々に数えても、superpowers pluginが重いという事実しか知りたくない局面があるからです。
判定ロジックは単純な分岐です。
if name == "Skill":
skill_name = inp.get("skill", "?")
if ":" in skill_name:
plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
skill_calls[skill_name] += 1
elif name == "Agent":
st = inp.get("subagent_type", "?")
agent_calls[st] += 1
elif name.startswith("mcp__"):
parts = name.split("__")
if len(parts) >= 2:
mcp_calls[parts[1]] += 1
ループは1ファイルを1行ずつ読んで json.loads するだけ。파싱エラーは try/except で無視。全部で30行足らずの集計コアです。
全体の流れ
スクリプトの処理フローをアスキー図で示します。
~/.claude/projects/
└─ -Users-<username>/
├─ abc123.jsonl ─┐
├─ def456.jsonl ├─► mtime >= cutoff? ─NO─► スキップ
└─ ghi789.jsonl ─┘ │
YES
│
jsonl 1行ずつ読む
│
message.content[]
│
type=="tool_use" のブロック抽出
│
┌──────────────┼──────────────┐
│ │ │
name== name== name starts
"Skill" "Agent" "mcp__"
│ │ │
input.skill subagent_type __split[1]
│ │ │
skill_calls agent_calls mcp_calls
│ │ │
└──────────────┴──────────────┘
│
Counter.most_common(10)
│
stdout へ出力
スクリプトの構成(全106行)
usage-breakdown.sh は3つのパートに分かれています。
Part 1: シェル層(1〜16行)
引数パース・トランスクリプトディレクトリの存在確認・Pythonスクリプトへの受け渡しを担います。
#!/usr/bin/env bash
set -uo pipefail
ARG="${1:-7d}"
TR_DIR="$HOME/.claude/projects/-Users-<username>"
[ -d "$TR_DIR" ] || { echo "(no transcript dir)"; exit 0; }
python3 - "$TR_DIR" "$ARG" <<'PY'
<<'PY' ... PY のヒアドキュメントでPythonコードをインラインに埋め込んでいます。外部の .py ファイルを置かずに1ファイルで完結させるための構造です。インストール不要・パス解決なし・どこから呼んでも動く、という運用上の利点があります。
Part 2: 引数解析と時間窓の計算(18〜28行)
SHORT = arg == "--short"
days = int((arg if arg.endswith("d") else "7d").rstrip("d"))
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
--short フラグを分岐させた後、7d → 7 の数値変換。endswith("d") チェックで 30d 形式と純粋な整数の両方を受け付けます。
Part 3: ファイル走査と集計コア(37〜73行)
glob.glob でJSONLファイル一覧を取得し、mtimeフィルタを通過したものだけを開きます。1行ずつ json.loads → message.content のリスト走査 → tool_use ブロック抽出 → 4本のCounterへ加算、というパイプラインです。
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
total_files += 1
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
rec = json.loads(line)
msg = rec.get("message", {})
content = msg.get("content")
if not isinstance(content, list): continue
for block in content:
if block.get("type") != "tool_use": continue
name = block.get("name", "")
inp = block.get("input") or {}
tool_calls[name] += 1
# ... 4本の分岐
errors="replace" を渡しているのは、稀に含まれる不正バイトでファイル全体の読み込みが止まるのを防ぐためです。
Part 4: 出力(75〜106行)
--short なら1行サマリ、通常モードなら most_common(10) で上位10件をセクション別に出力します。
print(f"=== usage breakdown (last {days}d, {total_files} transcripts) ===")
print(f"\ntotal tool_use: {sum(tool_calls.values())}")
if skill_calls:
print(f"\n--- top skills ({len(skill_calls)} unique) ---")
for sk, n in skill_calls.most_common(10):
print(f" {n:>5} {sk}")
{n:>5} の右寄せフォーマットで、桁数が違っても縦棒が揃います。ターミナルで見たときの視認性のための細工です。
実際に7日分を流した出力
=== usage breakdown (last 7d, 39 transcripts) ===
total tool_use: 5015
--- top tools ---
3656 Bash
508 Edit
304 Read
240 Write
37 Monitor
35 ToolSearch
23 AskUserQuestion
20 TaskUpdate
19 mcp__plugin_playwright_playwright__browser_take_screenshot
16 mcp__claude-in-chrome__navigate
--- top skills (3 unique) ---
3 artifact-design
1 dataviz
1 claude-api
--- top agents (1 unique) ---
1 code-reviewer
--- top MCP servers (4 unique) ---
79 plugin_playwright_playwright
45 claude-in-chrome
15 claude_ai_Google_Calendar
2 claude_ai_Gmail
7日間で39セッション、ツール呼び出し総数5,015回。Bashが3,656回(72.9%)でダントツ首位、Editが508回で続きます。SkillとAgentは思ったより少ない——この数字が何を意味するかは次の章で掘ります。30日分に広げると絵が変わります。
=== usage breakdown (last 30d, 203 transcripts) ===
total tool_use: 21215
--- top agents (7 unique) ---
94 general-purpose
22 Explore
6 reviewer
...
--- top MCP servers (5 unique) ---
1571 claude-in-chrome
81 plugin_playwright_playwright
54 computer-use
30日スパンで見ると claude-in-chrome が1,571回——週換算で約366回です。Agentでは general-purpose が94回(週換算23回)。7日窓では見えにくかった定常的な重さが、30日窓で浮かび上がります。
このギャップ——短窓では見えず、長窓で初めて見える重さ——が、定期実行される自動化がらみのチューニングポイントになります。
実装の詳細——「なぜそう書くか」を掘る
二重 try/except の設計意図
スクリプトの集計コア(37〜73行)を読むと、try/except が2層になっていることに気づきます。
for path in glob.glob(f"{tr_dir}/*.jsonl"):
try:
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
total_files += 1
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
try:
rec = json.loads(line)
except: continue # ← 内側
...
except Exception:
continue # ← 外側
内側の try/except は json.loads だけを包みます。JSONLは1行1レコードの形式なので、1行がパース失敗しても残りの行は読み続けられます。continue で次の行へ飛ぶだけです。
外側の try/except Exception はファイル単位の例外を受けます。パーミッションエラー、ファイルが削除された、mtime取得がコケた——どれが起きても continue でそのファイルをスキップし、次のファイルへ進みます。total_files のカウントが外側の try の中にあるのはそのためです。ファイルを正常に開けた時だけカウントしたい。
2層にする理由は、粒度の違いです。「このファイルが読めない」と「この行がJSONでない」は別の障害で、処理の継続範囲が違います。1層にまとめてファイルごと continue していたら、先頭行が壊れた1ファイルで残りの何千行かを丸ごと捨てることになります。
isinstance ガードの徹底
47行目には、一見くどく見えるガードが入っています。
msg = rec.get("message", {}) if isinstance(rec.get("message"), dict) else {}
rec.get("message", {}) だけでよさそうに見えますが、これは足りません。transcript.jsonlには "message": null というレコードが存在します。null はJSONとしては正当なので json.loads を通過しますが、Pythonでは None になります。{}.get("content") は問題ないですが、None.get("content") は AttributeError で落ちます。isinstance で dict であることを確認してから .get() を呼ぶ、というパターンを入れないと、null レコードを踏むたびに内側の except が拾い続けます。
同じ理由で54行目にも防御があります。
inp = block.get("input") or {}
block.get("input") は None を返すことがあります。None or {} は {} になるので、以降の inp.get("skill", "?") が安全に動きます。if inp is None: inp = {} と書くより短く、「Noneと空dictの両方に対して空dictを使いたい」という意図が1行で伝わります。
さらに50行目。
for block in content:
if not isinstance(block, dict): continue
content は list だと確認済みですが、その要素が全て dict とは限りません。Claude Codeのtranscriptを眺めると、content が文字列のリストになっているレコードも稀に出てきます(テキストブロックとツールブロックが混在している場合の一部)。要素ごとに isinstance(block, dict) を確認して、dictでなければスキップするのが堅牢です。
ヒアドキュメントのクォートが命綱
16行目を注意深く見てください。
python3 - "$TR_DIR" "$ARG" <<'PY'
<<'PY' のシングルクォートが絶対に必要です。<<PY(クォートなし)にしてしまうと、ヒアドキュメントの内側でシェルの変数展開が走ります。Pythonコード中に $tr_dir という書き方が一箇所でもあれば、シェルが展開しようとして意図しない文字列に化けます。f"{tr_dir}/*.jsonl" はPythonのf-stringなので$はありませんが、$1や${HOME}に見える文字列があると壊れます。<<'PY'のようにデリミタをクォートで囲むと、ヒアドキュメント内の展開が完全に無効化され、Pythonコードがそのままの文字列としてpython3の標準入力に渡されます。
ヒアドキュメントでPythonをインラインに埋める利点は、1ファイルで完結することです。スクリプトをどこかのディレクトリに置いてPATHを通しておけばそれだけで動く。~/.claude/scripts/usage-breakdown.sh をlaunchdから呼ぶ場合、Pythonファイルのパスを別途管理する必要がありません。外部ファイル依存は、そのファイルが消えたり移動した瞬間に黙って壊れます。
plugin namespace の分離ロジック
59〜61行目のブロックは小さいですが、実際に使ってみると価値がわかります。
if ":" in skill_name:
plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
skill_calls[skill_name] += 1
split(":", 1) の 1 が重要です。最大分割数を1に限定することで、expo:eas-hosting なら ["expo", "eas-hosting"] になりますが、仮に expo:eas:hosting という形のスキル名があっても ["expo", "eas:hosting"] になり、namespace部分だけを正確に取り出せます。
plugin_skill_calls と skill_calls の両方にカウントを入れているのは集計の軸を分けるためです。skill_calls は個別スキル名を、plugin_skill_calls はnamespaceを集計します。週次レポートで「expoプラグインを合計12回使った」という束ねた数字と、「expo:eas-hostingが5回、expo:expo-upgradeが4回」という内訳を、どちらも手元で引けます。
--short フラグとステータスバー統合
--short モードが返す1行は、macOSのステータスバーウィジェット(xbar、Übersicht等)から直接呼んで表示するためのものです。
5015 tool_use across 39 sessions (7d)
launchdのplistで5分おきにスクリプトを走らせ、結果を /tmp/usage-short.txt に書き出して、ウィジェットがそれを読むという構成です。ウィジェット側でファイルを読むだけなので、Claude Codeのセッション中に定期起動しても競合しません。--short フラグがなければ出力が10行以上になり、ウィジェットに埋め込むには長すぎます。用途別に出力形式を切り替えるオプションを最初から設計に入れておくと、後からはまりません。
私が詰まった話
詰まり①:errors="replace" を入れていなかったら全ファイルが通らなかった
最初のバージョンには errors="replace" がありませんでした。
with open(path, "r", encoding="utf-8") as f: # ← errorsなし
これで走らせると、一部のtranscriptファイルで UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position ... が出てスクリプトが止まります。ファイルを外側の try/except Exception で囲んでいたので止まりはしませんが、そのファイル全体が continue でスキップされます。
原因はtranscriptにPNG画像のbase64データが含まれている場合です。Claude Codeでスクリーンショットを使うセッションでは、画像がtranscript.jsonlにbase64エンコードで書き込まれます。base64文字列自体はASCIIなのでUTF-8で読めますが、稀にバイナリが混入した不正なJSONLが生成されることがあります。errors="replace" を渡すと、読めないバイトを U+FFFD(REPLACEMENT CHARACTER)に置換して読み進めます。JSON値の中の壊れたバイトが置換文字になっても、json.loads のパース対象はその行全体なので、構造が崩れていなければパースは通ります。構造が崩れていれば内側の except: continue が拾います。
errors="replace" は情報の欠損を許容する代わりに、ファイル全体の読み込みを完走させる選択です。usage集計では1バイトの精度より「全ファイルを走査できたか」の方が大事なので、この判断は正しかったです。
詰まり②:mtime窓が「偽陽性」を大量に返した
スクリプトをしばらく使っていると、直近7日の集計なのに「明らかに古いセッションのデータが混入している」と気づく日がありました。出力の総件数が普段の3〜4倍に膨らんでいて、しかも内容を見ると2週間前のやりとりが含まれています。
原因はバックアップソフトのリストアでした。Time Machineやrsyncでホームディレクトリを同期すると、~/.claude/projects/ 以下のファイルが上書きコピーされます。コピーはファイルの作成時刻を変えますが、mtimeも「コピーした時刻」になります。中身は古いセッションのtranscriptなのに、mtimeは今日の日時です。
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
mtimeフィルタは「このファイルを最後に書き換えた時刻」を見るので、コピーによってmtimeが更新されたファイルはすべて「最新」扱いになります。164ファイルが一括で偽陽性になり、5窓連続で「新規ゼロなのに数字が膨らむ」という状態が続きました。
根本的な解決は、transcript内の 日時: フィールドをパースして実際のセッション時刻で判定することです。ただしそれは実装コストが上がるため、現在は30日窓で集計して長期トレンドを見るというワークアラウンドを採用しています。一括コピーで偽陽性が混入しても、30日分の総量の中では統計的な外れ値に収まります。7日窓を使う場合は、バックアップ直後の数日は数字を信用しないという運用ルールを自分で持つしかありません。
この問題に気づかないまま「先週はSkillが100回呼ばれた」という数字を信じてplistのStartIntervalを変えていたら、実際には変更が不要な場所を触っていたかもしれません。ツールの出力を鵜呑みにする前に「この数字の取得方法は何に影響されるか」を一度疑う、というクセが必要だと学びました。
詰まり③:json.loads の例外をブランケットキャッチしたら副作用が見えなくなった
内側の except: continue は except Exception: continue ではなく、裸の except: です。これは BaseException を含む全例外を捕まえます。KeyboardInterrupt も SystemExit も全部飲み込みます。
最初はこれで良いと思っていましたが、デバッグ中に Ctrl+C でスクリプトを止めようとしたら止まらないことがありました。KeyboardInterrupt が内側の except: に捕まって continue されていたのです。ループが進んで次のファイルに入れば外側の try も except も越えられませんでした。
直し方は内側を except (json.JSONDecodeError, ValueError): continue に絞ることです。json.loads が失敗する理由は実質的に JSONDecodeError(Python 3.5以降)か、稀に ValueError だけです。それ以外の例外(KeyboardInterrupt を含む)は内側で捕まえず、外側の except Exception に伝播させるか、ユーザーに届かせます。現在のコードでは except: のままになっており、これは今でも改善の余地があると思っています。実際の運用では問題が出たことはありませんが、「Ctrl+C が効かない可能性がある」という挙動は知っておくべきです。
詰まり④:most_common の返り値をソートし直そうとして二度手間だった
集計結果を出す段階で、skill_calls.most_common(10) の返り値をさらに sorted() で並べ替えようとしたことがありました。アルファベット順でも出したかったからです。
# やってしまったパターン
for sk, n in sorted(skill_calls.most_common(10), key=lambda x: x[0]):
print(f" {n:>5} {sk}")
これは most_common(10) を先に取ってから名前順に並べ直すので、「全体の上位10件をアルファベット順にした」という結果になります。一見問題なさそうですが、「頻度11位のSkillが名前順で上位に来るはずなのに出ない」という混乱が起きます。
most_common() の役割はカウンターを頻度降順で返すことです。引数 10 は頻度上位10件だけに絞る。その後でソートするなら、most_common() に引数を渡さずに全件取ってからソートするか、最初から目的に合った別のデータ構造を使うべきでした。
この件は1行の直し方で済みましたが、「CollectionsのCounterはどう動いているか」を理解せずに使っていたことが問題でした。Counter は内部的には dict のサブクラスで、most_common() はヒープを使った O(n log k) の操作です。100万件あっても上位10件は高速に返ります。逆に全件取得から自力でソートするのは O(n log n) になります。規模が小さければ誤差ですが、将来transcriptが増えたときに差が出ます。
詰まり⑤:MCP サーバー名が空文字になるケースがあった
mcp__<server>__<tool> の形式からサーバー名を取り出す部分です。
elif name.startswith("mcp__"):
parts = name.split("__")
if len(parts) >= 2:
mcp_calls[parts[1]] += 1
if len(parts) >= 2: のガードを外していた初期バージョンで、mcp__ だけのツール名(parts が ["mcp", ""])が混入したとき、parts[1] が空文字列になって mcp_calls[""] += 1 が積み上がっていました。出力に " 23 " という空のエントリが出てきて最初は何だかわかりませんでした。
空ツール名が発生する原因は不完全なレコードです。まれにMCPのレスポンスが中断されてツール名が途中で切れたtranscriptが生成されます。len(parts) >= 2 のガードで弾くのが一番シンプルな対処で、これを入れたら空エントリは消えました。さらに言えば parts[1] が空文字の場合もスキップしたいので、本来は if len(parts) >= 2 and parts[1]: とすべきでした。現在のコードでは parts[1] が空の場合は弾かれずに mcp_calls[""] になりますが、出力では most_common(10) に含まれるほどの件数にはなっていないので実害はありません。
つまずきポイント
前の章で詳述した5件(UnicodeDecodeError・mtimeの偽陽性・裸のexcept:・most_commonの二度手間・空MCPサーバー名)以外に、実際の運用でぶつかった細かい罠をまとめます。
-
TR_DIRがハードコードされていて他人の環境で動かない。スクリプト13行目は
TR_DIR="$HOME/.claude/projects/-Users-yourname"のようにユーザー名が埋まっています。別アカウントや別マシンに持ち込もうとして動かなかった。-Users-$(whoami)で解決できますが、そもそもディレクトリ名のスラッシュがハイフン置換されている命名規則を知らないと原因がつかめません。 -
--shortフラグと日数指定が排他になっている。引数は$1の1つだけを受け取るのでusage-breakdown.sh --short 30dのように書いても30dは無視されます。「30日分の1行サマリが欲しい」という組み合わせが素直に書けない。実際はusage-breakdown.sh 30dの出力の1行目だけ取るか、スクリプト側を$1/$2対応に改造するしかありません。 -
launchdから呼ぶと
python3のPATHが通っていない。launchdで起動したスクリプトは/usr/bin:/bin:/usr/sbin:/sbinだけのPATHで動きます。homebrewやnvmが入れたpython3は/usr/local/binや/opt/homebrew/bin等にあるため、素のlaunchd plistではpython3: command not foundになります。plistの<key>EnvironmentVariables</key>に<key>PATH</key>を明示するか、スクリプトの先頭で/usr/bin/env python3でなく絶対パス(/opt/homebrew/bin/python3)を指定する必要があります。 -
StartInterval変更後にplistを再ロードしていなかった。
~/Library/LaunchAgents/com.lily.usage-breakdown.plistのStartIntervalを300(5分)から1800(30分)に直しても、launchctl unload+launchctl loadを忘れると古い設定のまま動き続けます。変更が効いているかはlaunchctl list com.lily.usage-breakdownでLastExitStatusと次回起動時刻を確認するのが確実です。ファイルだけ書き換えた気になって、実は変わっていなかった——という状態に数日気づかなかったことがあります。 -
glob.globの返り順が保証されない。ファイルシステムによって並び順が変わります。集計結果は合計が同じでも、ファイルを処理する順序が変わるとtotal_filesのカウントには影響しません(カウンターは累積なので)が、デバッグで特定のファイルが何番目に処理されているか追うときに順序が毎回変わるので混乱します。確実に順序を固定したいならsorted(glob.glob(...))と明示する方が安全です。 -
most_common(10)の上限が固定で、Skillが増えると下位が見えなくなる。環境にインストールされているSkillが50を超えると、10位以下のSkillが集計から見えなくなります。週次チューニングの目的なら「100回以上のもの」という閾値で絞る方が現実的です。現在のコードは出力数がハードコードなので、環境が育つにつれて見たい情報が切り捨てられます。 -
7日窓の数字だけを見てplistを変更してしまった。7日間のトランスクリプト数が少ない週(連休明けなど)は絶対数が低く見えます。「
claude-in-chromeが20回しか呼ばれていない」は、普段が140回の週から見ると「今週たまたま少なかっただけ」です。30日窓との比較を必ずセットにしないと、外れ値で判断して不要なチューニングをしてしまいます。 -
contentがリストでなく文字列になっているレコードのガードを忘れた。isinstance(content, list)のチェックは入っていますが、最初のバージョンではmsg.get("content")だけで済ませていたために文字列が入ってきたときfor block in content:がcharの反復になってしまいました。1文字ずつisinstance(block, dict)を判定して全部落とすので実害はありませんでしたが、ループ回数が無駄に増えてファイルサイズが大きいtranscriptで体感できるほど遅くなりました。 -
スクリプトの出力を保存していなかったので時系列比較ができなかった。
usage-breakdown.sh 7dを手で叩いて眺めるだけでは、先週より増えたか減ったかが記憶頼みになります。launchdで週1回/tmp/usage-weekly-$(date +%Y-%m-%d).txtに書き出すように変えてから、「先月はMCPが週550回だったのが今月は280回に半減した」という比較が客観的にできるようになりました。 -
Skill名に
:を含む場合のsplitがplugin_skill_callsにだけ入る意図を誤読した。plugin_skill_callsとskill_callsの両方にカウントを入れているのは集計軸を分けるためですが、最初はバグだと思ってskill_callsへの加算を消しました。結果として個別スキル名の集計が全て?になり、「Skillが呼ばれているのに名前が全部不明」という出力が出て混乱しました。コードを読むときは判定分岐のelifでなくifが使われている理由を確認するのが大切です。
ベストプラクティス
1. ヒアドキュメントのデリミタは必ずシングルクォートで囲む
<<'PY' と書かないとPythonコード内に $ が含まれるとき(f-stringや $HOME に見える部分)でシェル展開が走ります。<<PY と書いて動いているうちはたまたまで、$tr_dir という変数名を増やした瞬間に壊れます。インラインPythonスクリプトを扱う場合のルールとして固定しておきます。
2. open() は常に errors="replace" を渡す
ログ・transcriptの類はバイナリが混入する可能性があります(スクリーンショットのbase64、外部コンテンツのコピー等)。errors="replace" はデータの精度よりも「全ファイルを走査できたか」を優先する集計系の処理に向いています。完走率を上げるための一手です。
3. try/except は粒度ごとに2層に分ける
「ファイル単位の失敗」と「1行単位の失敗」は継続範囲が違います。外側はファイルスキップ、内側は行スキップ——この2層構造を最初から設計に入れると、デバッグ時に「何行目が壊れているか」と「どのファイルが壊れているか」を分離して追えます。
4. isinstance ガードは見た目がくどくても省略しない
rec.get("message", {}) は "message": null を弾けません。isinstance(rec.get("message"), dict) の1行が、NoneでAttributeErrorを出す経路を完全に塞ぎます。transcript.jsonlは仕様外の値が普通に含まれるため、型を信頼せず毎回確認するのが安全です。
5. Noneと空dictを同時に対処する or {} パターン
inp = block.get("input") or {}
if inp is None: inp = {} より短く、意図が明確です。or 演算子はfalsyな値(None・空dict・空文字列)を全て {} に置き換えるので、後続の .get() を安全に呼べます。
6. 集計軸を複数のCounterに持つ
skill_calls(個別名)と plugin_skill_calls(namespace)の2本を持つことで、「expoプラグイン全体が重い」という上位の視点と「expo:eas-hostingが5回」という個別の視点を同じ実行で取り出せます。後から軸を追加するより、設計段階で「何単位で見たいか」を整理しておく方が楽です。
7. 7d窓と30d窓を必ずセットで使う
7日窓はノイズに敏感です。連休・バックアップ復元・大型作業週のどれかが混入すると外れ値になります。30日窓を並べて「定常的に多いか、今週だけ多いか」を判定してからplistに手を入れる——この2ウィンドウ運用がチューニング判断のブレを防ぎます。
8. --short フラグで用途別出力を設計段階から分ける
人間が読む詳細モードと、ウィジェット・ログファイルに食わせる1行モードを最初から分けておくと、同一スクリプトを複数の文脈で使い回せます。出力形式を後から追加しようとするとコードの分岐が増えて見通しが悪くなります。
9. 週次ログをファイルに書き出して時系列を残す
~/.claude/scripts/usage-breakdown.sh 7d > /tmp/usage-$(date +%Y-%m-%d).txt
これをlaunchdで毎週月曜に走らせるだけで、4週前との比較が diff で見られます。「最近コストが上がった気がする」という感覚を数字で検証するとき、手元にログがあるかないかで話が全く変わります。
10. plist変更後は必ず unload → load の2ステップを踏む
launchctl unload ~/Library/LaunchAgents/com.lily.usage-breakdown.plist
launchctl load ~/Library/LaunchAgents/com.lily.usage-breakdown.plist
ファイルを書き換えただけでは反映されません。launchctl list com.lily.usage-breakdown の "NextScheduledFire" を見て次回起動時刻が新しい StartInterval に沿っているかを確認する習慣をつけます。
11. launchdのplistにPATHを明示する
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
homebrewやnvmのツールを使うスクリプトをlaunchdから動かす場合、これがないと command not found で黙って失敗します。/usr/bin/env python3 だけでは足りない環境があります。
12. MCPサーバー名の空文字ガードは parts[1] まで確認する
if len(parts) >= 2 and parts[1]:
mcp_calls[parts[1]] += 1
len(parts) >= 2 だけでは mcp__ → ["mcp", ""] の空文字が通り抜けます。and parts[1] を足すことで、空キーが most_common に混入するのを防げます。
13. スクリプトは1ファイル完結で設計する
外部Pythonファイルへの依存を持つと、ファイルが移動・削除されたときに黙って壊れます。<<'PY' ... PY のインラインヒアドキュメント方式は、スクリプト1本をどこに置いてもそのまま動く自己完結型にする最も手軽な方法です。
14. 集計結果の数字を「取得方法への信頼」と切り離して読む
mtime窓の話がその典型です。数字が出ても、その数字の根拠——どのファイルのどのフィールドを読んでいるか——を理解していないと、誤った前提でチューニングを進めます。スクリプトの動作を1度手で追って「バックアップ後に偽陽性が出る」という限界を把握してから、定常運用に乗せる順序が大切です。
まとめ
Claude Codeの /usage が出すのは「モデル別の合計額」だけです。106行の usage-breakdown.sh はそのギャップを埋めるために書いたスクリプトで、transcript.jsonlをPythonでパースしてSkill・Agent・MCPサーバーごとの呼び出し回数をCounterで集計します。
実際に7日分を流すと Bash: 3,656回(72.9%) という実態が見え、30日分まで広げると claude-in-chrome: 1,571回(週366回換算) という定常的な重さが浮かびます。この数字を基に、週換算100回以上のコンポーネントを特定してplistのStartIntervalを調整する——それが本稿の手順全体のゴールでした。
つまずきポイントをならべると多く見えますが、全て「実コードを読んだ後でしか気づけなかった落とし穴」です。スクリプトの30〜50行目の集計コアを1度手で読んで動作を追えば、それだけで半分は予防できます。残りはlaunchdとの組み合わせ特有の環境依存で、PATHとunload/loadを押さえれば片付きます。
月商120万の自律環境を支えているのは、個々のSkillやMCPサーバーの賢さだけでなく、どのコンポーネントがどれだけ動いているかを数字で見られる仕組みが常時回っていることです。計測できないものは改善できません。手元のtranscript.jsonlに同じデータが既に溜まっているので、今日中に動かせます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!