cc-usage-guard を作った — Claude Code の rate_limits を status line で記録し PreToolUse フックで閾値到達時にセッションを止める
Claude Code のエージェントセッションを走らせたまま別の作業に移ると、戻ってきたときには 5 時間枠や 7 日枠の quota が尽きている、ということが何度かありました。使用率が 90% を超えてから自分で claude セッションを閉じれば済むのですが、見ていない間は閉じられません。閾値を超えた次のツール呼び出しでセッションが自動で止まれば、残った quota で手動の修正や短い確認を回せます。
この挙動を 2 つのシェルスクリプトと 1 つの状態ファイルにまとめた cc-usage-guard を公開しました。初版の PR は codenote-net/cc-usage-guard#1、リリースは v0.1.0 です。本記事では、rate_limits を status line からしか取れないという Claude Code 側の制約と、それに合わせて選んだ設計上の判断を書き留めます。Anthropic 非公式のツールで、Claude.ai の Pro・Max・Ultra plan 向けです。
何をするか
インストール後、claude セッションで最初の応答が返ると status line に 5h:xx% 7d:xx% が出て、サブスクリプションの残量がその場で見えます。
Fable 5.1 | 5h:42.5% 7d:63%閾値(既定 90%)を超えた状態で次のツール呼び出しかプロンプト送信が走ると、フックがセッションを停止します。
cc-usage-guard: 5-hour window at 91% (resets 2026-11-08T09:26:40Z). Threshold is 90%.
Session stopped to preserve remaining quota. Wait for the reset, or raise the
threshold via CLAUDE_USAGE_GUARD_THRESHOLD or ~/.claude/usage-guard/config.json.リセット時刻が本文に入るので、どの window がいつ戻るかを判断できます。閾値は環境変数 CLAUDE_USAGE_GUARD_THRESHOLD か config.json の threshold キーで変更でき、監視対象の window(five_hour・seven_day・spend_limit)は windows で選べます。
rate_limits は status line からしか取れない
Claude Code のフック仕様を調べると、PreToolUse や UserPromptSubmit に渡される JSON には rate_limits が含まれません。一方で、Claude Code 2.1.80 以降の status line コマンド には、更新のたびに rate_limits.five_hour.used_percentage などが入った JSON が標準入力として渡されます。
この非対称が設計の出発点でした。使用率を参照できるのは status line だけなので、状態を取るプロセスと、使って停止判断を下すプロセスを分ける必要があります。cc-usage-guard の構成は次のようになりました。
flowchart TD
TUI[Claude Code TUI] -->|status line JSON<br/>rate_limits 入り| SL[statusline.sh]
SL -->|atomic write| STATE[(~/.claude/usage-guard/usage.json)]
SL --> BAR[status バー表示<br/>5h:xx% 7d:xx%]
TUI -->|PreToolUse / UserPromptSubmit<br/>rate_limits なし| HK[guard-hook.sh]
STATE -->|jq で読み出し| HK
HK -->|閾値未満| GO[そのまま継続]
HK -->|閾値到達| STOP["continue: false<br/>stopReason: ..."]
status line 側がライター、フック側がリーダーに回り、両者の間は ~/.claude/usage-guard/usage.json だけでやり取りします。Claude Code のプロセスが両者を独立に呼ぶので、プロセス間で直接の IPC は要りません。
statusline.sh は状態を書きつつ既存 status line を passthrough する
statusline.sh は status line の JSON を標準入力で受け、rate_limits を jq で抽出して状態ファイルに保存し、同時に短いサマリを stdout に書きます。1 回の jq 呼び出しで 2 行を出力し、bash 側で改行で分割しています。
out=$(printf '%s' "$input" | jq -r --arg now "$now" '
def pct($w): .rate_limits[$w].used_percentage? // null;
def reset($w): .rate_limits[$w].resets_at? // null;
def part($name; $v): if $v == null then empty else $name + ":" + ($v | tostring) + "%" end;
(pct("five_hour")) as $five
| (pct("seven_day")) as $week
| ...
| (if ($five == null and $week == null and $spend == null) then ""
else ({five_hour: $five, seven_day: $week, spend_limit: $spend,
resets_at: {...}, updated_at: ($now | tonumber)} | tojson)
end),
(...) # summary 行
')
state=${out%%$'\n'*}
summary=${out#*$'\n'}状態ファイルの書き込みは mv を使った atomic update です。$STATE_FILE.tmp.$$ に書いてから mv -f で差し替えるので、guard-hook.sh が途中まで書かれた JSON を読む経路がありません。
tmp="$STATE_FILE.tmp.$$"
if printf '%s\n' "$state" > "$tmp" 2>/dev/null; then
mv -f "$tmp" "$STATE_FILE" 2>/dev/null || rm -f "$tmp"
fi既に別の status line コマンドが登録されていた場合、install.sh はその値を config.json の passthrough に避難させます。実行時には statusline.sh が passthrough を bash -c で呼び、その出力の後ろに使用率だけを追記します。既存の status line を置き換えず、情報を 1 行末尾に増やす形です。
guard-hook.sh は単発 jq で判定し fail-open する
guard-hook.sh は PreToolUse・UserPromptSubmit の両方に登録されます。フック設計のドキュメントに従い、stdin に渡されるイベント JSON をまず捨ててから、~/.claude/usage-guard/usage.json を読み、閾値以上の window があれば {"continue": false, "stopReason": "..."} を stdout に書いて exit 0 で終わります。
決定ロジックは jq 1 回に閉じ込めました。PreToolUse はツール呼び出しのたびに走るので、ここでプロセスを何度も起動するとツール呼び出しごとの待ち時間が増えます。ついでに bash の条件式を増やさず済み、分岐が 1 箇所に集まります。
result=$(jq -r \
--argjson now "$now" \
--argjson config "$config" \
--arg env_threshold "${CLAUDE_USAGE_GUARD_THRESHOLD:-}" \
--arg env_max_age "${CLAUDE_USAGE_GUARD_MAX_AGE:-}" \
--arg env_windows "${CLAUDE_USAGE_GUARD_WINDOWS:-}" \
--arg config_path "$CONFIG_FILE" \
'
...
($config.threshold // 90 | num_or(90)) as $cfg_threshold
| (if $env_threshold == "" then $cfg_threshold else ($env_threshold | num_or($cfg_threshold)) end) as $threshold
| ...
| if ($now - $updated) > $max_age then empty
else
[ $windows[]
| . as $w
| ($state[$w] | if type == "number" then . else null end) as $v
| select($v != null and $v >= $threshold)
| { window: $w, value: $v, resets_at: ($state.resets_at[$w]? // null) }
] as $over
| if ($over | length) == 0 then empty
else ... end
end
' "$STATE_FILE" 2>/dev/null) || exit 0重要なのは || exit 0 と、すべての前提チェックで exit 0 に抜ける fail-open の扱いです。guard-hook.sh は次のどのケースでもセッションを止めません。
- 状態ファイルが存在しない(インストール直後、デスクトップ用途、
claude -pでしか使っていないなど) - 状態ファイルが
max_age_seconds(既定 1 時間)より古い jqが存在しない、または jq がエラーを返した- 入力 JSON が想定外で解析できない
ここで fail-closed(迷ったら止める)にすると、何らかの理由で状態ファイルの更新が遅れたときに作業自体が止まってしまい、ツールの役割を外れます。fail-open にしておき、「新しい状態があり、かつその使用率が高いときにだけ止める」ことに責任を絞っています。stale フォールバックの代わりに max_age_seconds を設けたのは、長時間放置した後に、過去の高い使用率で始めた瞬間止まることを避けるためです。
plan ごとに届く window が違う
実装時に Ultra アカウントで CLAUDE_USAGE_GUARD_DEBUG=1 claude を動かし、生の status line JSON を覗いたところ、Ultra では rate_limits.five_hour が存在せず seven_day だけが返ってきました。ドキュメントは各 window が独立に欠けうるとだけ書いており、plan ごとの差までは書いていませんが、実際に Pro・Max・Ultra の挙動が異なります。README の Known limitations には以下のように整理しました。
| plan | 送られてくる window |
|---|---|
| Pro / Max | five_hour と seven_day |
| Ultra | seven_day のみ |
| Team / Enterprise | rate_limits 自体が送られない(未対応) |
| Claude apps gateway 経由 | spend_limit(他の window は構成による) |
このため既定の windows は ["five_hour", "seven_day"] にしてあり、spend_limit はユーザーが明示的に追加する opt-in です。statusline.sh は値が存在しない window はサマリから落とし、guard-hook.sh は type == "number" をチェックしてから閾値比較に入るので、Ultra で five_hour が無くても誤判定しません。
installer は jq でマージし、再実行しても壊さない
Claude Code の設定は ~/.claude/settings.json に書きます。既存の statusLine や hooks 設定を壊さないように、install.sh は jq でマージします。
statusLine.commandが cc-usage-guard 以外のコマンドなら、その値をconfig.jsonのpassthroughに移してからstatusline.shに差し替える- 既に cc-usage-guard の
statusline.shが登録されていたら、passthroughを上書きしない PreToolUse・UserPromptSubmitのフック配列に、同じguard-hook.shを指すエントリが既にあれば追加しない
該当ロジックは jq 側の has_hook と add_hook 関数にまとめています。
def has_hook($event): any((.hooks[$event] // [])[]; any((.hooks // [])[]; (.command // "") | test("usage-guard/guard-hook\\.sh")));
def hook_entry($with_matcher):
(if $with_matcher then {matcher: "*"} else {} end)
+ {hooks: [{type: "command", command: $hook_cmd, timeout: $timeout}]};
def add_hook($event; $with_matcher):
if has_hook($event) then . else .hooks[$event] = ((.hooks[$event] // []) + [hook_entry($with_matcher)]) end;PreToolUse は matcher でツールを絞れる(installer は "*" を明示)のに対し、UserPromptSubmit は matcher を持たず付けても無視される、というフック仕様の差もここで吸収しています。書き込みは settings.json.tmp.$$ に出してから mv -f で差し替える atomic update で、Claude Code のプロセスが同じファイルを読んでも中途半端な JSON を読まないようにしています。
uninstall.sh は、追加した guard-hook.sh を指すエントリだけを削除し、statusLine が usage-guard/statusline.sh のときだけ passthrough を書き戻します。他人のフック・他人の status line は一切触りません。既存 settings.json に対する install → uninstall の往復は、jq -S で正規化した結果を見ても byte 一致します。
config.json と環境変数の二層を用意する
設定は ~/.claude/usage-guard/config.json にあり、環境変数が上書きします。恒常設定は config、1 回だけ上げたいときは環境変数、という使い分けです。
| key | 既定値 | 環境変数 | 意味 |
|---|---|---|---|
threshold | 90 | CLAUDE_USAGE_GUARD_THRESHOLD | この使用率以上で停止 |
max_age_seconds | 3600 | CLAUDE_USAGE_GUARD_MAX_AGE | 状態ファイルがこれより古ければ無視 |
windows | ["five_hour","seven_day"] | CLAUDE_USAGE_GUARD_WINDOWS | 監視対象の window |
passthrough | null | なし | 元の status line コマンド |
たとえば次のように 1 回だけ閾値を 95% に上げられます。
CLAUDE_USAGE_GUARD_THRESHOLD=95 claudeもう追い込まれているときに「このセッションだけ 100% まで使ってよい」と宣言したいので、恒常的に 90% にしておきつつ、必要なときに環境変数で緩める形に落ち着きました。
CI は bash 3.2 の macOS を意識する
macOS の /bin/bash はいまだに 3.2.57 で、連想配列・local -n・[[ -v var ]] が使えません。ローカルで bash 5.x を使って通していても、CI が macOS 3.2 で落ちることがあります。GitHub Actions ワークフローでは ShellCheck を ubuntu-latest で、bats-core を ubuntu-latest と macos-latest の両方で走らせ、macOS では /bin/bash を明示的に使ってスクリプトを呼んでいます。61 本の bats テストは、使い捨ての HOME を渡して実際のインストール先と完全に分離したうえで、install・uninstall・statusline・hook の挙動を検証しています。
実際に書き進めるうちに、local -n や [[ -v var ]] といった bash 4 以降の構文に手が伸びてしまう場面が何度かありました。set -u で未定義変数を禁止しつつ、可能な範囲を POSIX の parameter expansion に寄せて ${var:-default} や ${var%%pat*} でしのいでいます。
v0.1.0 に入っていないこと
次は意図的に入れていません。
- Team / Enterprise plan 対応: Claude Code が
rate_limits自体を送らないため、同じ経路では実装できません。使用状況を取れる別経路が公開されたら検討します。 - 非対話セッションのカバレッジ:
claude -pやデスクトップアプリは status line にrate_limitsを流さないので、状態ファイルは更新されません。max_age_secondsで stale データを無効化して、少なくとも誤停止はしない形にしました。 - ストリーミング中の割り込み: フックはツール呼び出し前またはプロンプト送信時にしか走らず、すでに走っている 1 発のモデル応答を途中で止めることはしません。
- バックグラウンドリフレッシャ: アイドル中のセッションでは status line が更新されないため、画面を見ていない間の使用率が更新されない期間ができます。
statusLineのrefreshIntervalでコマンドの再実行はできますが、rate_limitsの値は API 応答がないと更新されません。別プロセスでrate_limitsを取得する方法を Claude Code が提供するか、別の API 経由が整うのを待ちます。 - Windows / Homebrew / npm: v0.1.0 では
bash+jqの環境しか対象にしていません。macOS・Linux でのcurl | bash導入を優先しました。
Claude Code の挙動がもう一段抽象化されれば削れそうな制約もあるので、issue での報告を歓迎します。
まとめ
Claude Code の rate_limits は status line コマンドにしか届かない、という 1 点を受け入れると、状態ファイルを介した status line → hook のパイプラインに自然に落ちます。cc-usage-guard v0.1.0 は、statusline.sh が atomic に状態を書き、guard-hook.sh が単発 jq で判定し、install.sh が既存の status line を passthrough に避難させる、という 3 つの小さな決め事で成り立っています。fail-open と max_age_seconds で stale データに過剰に反応しないようにしつつ、鮮度のある使用率が閾値を越えたときだけ continue: false でセッションを止める、という責任範囲に絞ってあります。
Pro・Max・Ultra の Claude.ai サブスクリプションで Claude Code 2.1.80 以降を使っている方は、次のコマンドで試せます。install.sh はスクリプト本体を CLAUDE_USAGE_GUARD_REF(既定 main)が指す ref からダウンロードするので、v0.1.0 に固定するには環境変数も合わせて渡します。
curl -fsSL https://raw.githubusercontent.com/codenote-net/cc-usage-guard/v0.1.0/install.sh | CLAUDE_USAGE_GUARD_REF=v0.1.0 bashアンインストールは bash ~/.claude/usage-guard/uninstall.sh で、追加したフックと status line を戻し、~/.claude/usage-guard/ を削除します。
以上、Claude Code の rate_limits を status line で記録し PreToolUse / UserPromptSubmit フックで閾値到達時にセッションを止める cc-usage-guard v0.1.0 を公開した、現場からお送りしました。