I used to find out I was near my Claude Code block limit the same way you find out your car is out of gas: when everything stopped moving. Building a hook to warn me earlier was the obvious fix — until the hook itself became the slowdown.
I made ¥100k/month as a university student, grew it to ¥600k across multiple gigs, got laid off, and spent six months building an autonomous Claude Code environment from scratch. Today it does ¥1.2M/month. At the core of that environment is "autonomous cost management built with hooks."
Claude Code's MAX plan is a fixed monthly price, but it isn't unlimited. There's an output token ceiling per 5-hour block, and once you burn through it, heavy work stops until the next block. When the hourly burn rate spikes, that's a sign you're hammering Opus back-to-back — and for lighter work, just switching to Haiku 4.5 buys you enough headroom to make it to the end of the block.
The problem is noticing too late. Somewhere past 500k tokens I'd start to feel the drag, and by 800k I'd realize "ah, I'm at the end of the block." I repeated this cycle every single day.
The first solution that comes to mind is "call ccusage in a UserPromptSubmit hook to check where you are." If it shows me the block status every time I submit a prompt, I can act early. I implemented exactly that — and another problem surfaced immediately.
The hook itself becomes the bottleneck.
ccusage is an external command that hits an API. Each call takes 200–400ms. In a workflow where you fire prompts rapid-fire — "tweak this," "now here," "shorter" — that latency translates directly into degraded UX. The hook generates cost, slows down perceived speed, and defeats its own purpose.
There is a third option between "call it every time" and "don't call it." The design: a re-run within 60 seconds returns the cached result.
That's the idea at the heart of cost_guard.sh
. Cost status doesn't change dramatically in a minute. If it was "normal" a minute ago, treating it as normal now is fine. Conversely, if a warning fired for crossing 800k tokens, that warning is also valid for 60 seconds. Inserting a cache effectively throttles ccusage calls down to once per minute even under rapid-fire prompting.
This idea is a direct answer to the structural trap where "automation that improves your efficiency gets cancelled out by the cost of the automation itself." The more hooks you install, the smarter your environment gets — but if the hooks themselves get heavy, your workflow stalls. Unless lightweight and fail-open are your governing principles, autonomy ties its own hands.
Two hooks split the responsibilities.
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
handles the real-time "where am I right now," and stop_cost_log.sh
handles the accurate record after the session ends. The former uses ccusage's block status (an estimate); the latter uses actuals computed from the transcript. Using both gives you a "flash report and a confirmed report."
Lines 12–17 at the top of the file are the entire cache decision.
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
gets the file's last-modified timestamp (Unix seconds) and computes the difference from the current time. If it's within 60 seconds, dump the cache to STDERR and exit 0. Simple, but two things matter here.
First, the 2>/dev/null || echo 0
safety net on stat -f %m
. On Linux, stat -f
doesn't work (GNU stat's format flag is -c %Y
). This script targets macOS, but the escape hatch is there so it fails silently if I ever move it to Linux. Returning echo 0
makes the age calculation equal $(date +%s)
, which always exceeds CACHE_AGE and therefore never hits the cache — it fails toward the safe side.
Second, the structure means ccusage is only called when the cache doesn't exist (i.e., on the first run). If the file is missing, [ -f "$CACHE" ]
is false and execution proceeds straight to the ccusage flow.
Lines 19–27 next are the ccusage call and fail-open handling.
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; }
The nvm path is hardcoded as a fallback for when command -v ccusage
fails. This addresses a Claude Code-specific trap: "the hook gets invoked in a bare shell environment where PATH isn't set up." Hooks don't run in an interactive shell; they run in an environment with a shorter-than-usual PATH.
gtimeout
(the GNU timeout provided by Homebrew on macOS) caps ccusage's max execution time at 5 seconds. ccusage not responding, network outage, API rate limiting — in every case it gets cut off at 5 seconds, writes an empty cache (: > "$CACHE"
), and exits 0. Writing the empty cache matters, because it records the state "completed normally, no warning" into the cache, letting the next 60 seconds treat it as if nothing happened. Without writing the empty cache, ccusage runs again on the next prompt.
When TIMEOUT_BIN
is empty (gtimeout not found), ${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}
expands to an empty string and ccusage runs without a timeout. This is also fail-open — use it if it's there, run bare if it isn't, work either way.
Looking at the threshold logic in lines 45–57 reveals another design intent.
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
The output token thresholds are two-tiered: 500k (past half the block) and 800k (end of block). The burn rate warning recommending Haiku fires at $80/hr or above.
printf '%b' "$WARN" > "$CACHE"
writes the cache regardless of whether there's a warning. Even when WARN
is empty, it writes an empty file. Without this, in the normal case (no warning) the cache never gets updated and ccusage keeps running on every prompt.
${OUT_TOK%.*}
is bash string manipulation that strips everything after the decimal point. Passing a float to -ge
(integer comparison) causes an error, so this defends against jq returning a string like "500000.0"
. The trailing 2>/dev/null
silences error output even if the comparison itself fails, so the whole script doesn't stop.
The Stop hook that runs at session end has the opposite design philosophy. Accuracy takes priority over speed.
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},
}
The pricing table is held per model and looked up by prefix match. transcript.jsonl can contain multiple models mixed together (on days when you switched between Opus and Sonnet), so rate_for(model)
applies the correct rate for each message.
The cache pricing calculation is precise: it checks both cache_creation_input_tokens
(the total) and the breakdown fields ephemeral_5m_input_tokens
/ ephemeral_1h_input_tokens
. There's also a fallback that treats the total as 5-minute cache when the breakdown is zero.
if cc_5m + cc_1h == 0 and cc_total > 0:
cc_5m = cc_total
Output is appended to ~/.claude/logs/cost-log.jsonl
in JSONL format. One line per session. This isn't ccusage's real-time estimate but actuals computed from the transcript, so it's trustworthy as a source for billing reports.
Past the cache and fail-open handling, lines 31–37 do the JSON parsing with jq.
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)
narrows down to just the currently running block even when multiple blocks come back. On the MAX plan there's generally one active block, but depending on when the command is issued, the previous block can occasionally slip in. Without the filter, you'd pick up the previous block's large consumption and get a false warning.
// 0
is jq's alternative operator. Even if .burnRate.costPerHour
is null, it returns 0 and downstream calculations don't break.
2>/dev/null
silences error output when jq itself fails (jq not installed, malformed JSON, etc.). ACTIVE
becomes empty and the next guard [ -z "$ACTIVE" ]
naturally exits. It matters that this guard is "write an empty cache and exit 0" — even on jq failure, it stays quiet for the 60 seconds until the next prompt.
${OUT_TOK%.*}
Means This form always appears right before the threshold comparison.
if [ "${OUT_TOK%.*}" -ge 800000 ] 2>/dev/null; then
Bash's -ge
only accepts integer comparison. But jq returns outputTokens
as a JSON number type, so it can become a floating-point string like "500000.0"
. Passing a string with a decimal point to -ge
is an error. ${OUT_TOK%.*}
is string expansion that strips everything from the trailing .
onward, converting "500000.0"
→ "500000"
.
There's also a trailing 2>/dev/null
. This is insurance that silences errors from the comparison expression itself. Even if OUT_TOK
is an empty string, null
, or an abnormal value like N/A
, the entire if branch silently evaluates to false and the script continues. Continuing without emitting a warning is more correct than erroring out and stopping. If the hook stops, Claude Code blocks prompt submission.
${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}
The ccusage call on line 27 looks like this.
BLOCK_JSON=$(${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json 2>/dev/null) || { : > "$CACHE"; exit 0; }
${var:+word}
is bash parameter expansion that behaves as "expand to word if var is non-empty, disappear if empty." If TIMEOUT_BIN
is /opt/homebrew/bin/gtimeout
, "$TIMEOUT_BIN" 5
expands and you get gtimeout 5 ccusage ...
. If TIMEOUT_BIN
is empty, nothing is inserted and you just get ccusage ...
. It handles both with and without timeout while fitting in a single line.
This expansion is set up on line 25.
TIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
If command -v
doesn't find it, try Homebrew's fixed path, and if that isn't executable either, reset to TIMEOUT_BIN=""
. Without that explicit "empty it if not found" fallback, a non-executable path string stays in TIMEOUT_BIN
and the expansion produces a broken command: /path/that/doesnt/exist 5 ccusage
.
The Stop hook isn't written in bash. In reality, bash launches 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
reads all of stdin, puts it on the env, then launches Python via heredoc. Why not pipe directly? In the python3 - <<'PY'
form, Python reads its code from stdin, so you can't pass data on stdin. Going through env makes the heredoc and the data coexist.
export STOP_INPUT="$INPUT"
stuffs a long JSON into an env variable. The JSON that Claude Code's hook passes in is a single line containing session_id
, transcript_path
, cwd
, hook_event_name
, and so on. Size isn't a problem.
Reading transcript.jsonl has errors="replace"
attached.
with open(tp, "r", encoding="utf-8", errors="replace") as f:
Transcripts can reach tens of MB in long sessions. Even if a corrupt UTF-8 byte sequence gets mixed in partway, errors="replace"
substitutes U+FFFD
and keeps reading. With errors="strict"
(the default), it dies with UnicodeDecodeError and the cost record becomes zero. I actually lost a week's worth of logs to this, which I'll cover below.
The per-message calculation looks simple, but the handling of cache pricing has fine-grained branching.
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
As of 2026, Anthropic's API returns cache creation tokens in two tiers. The field cache_creation_input_tokens
is the total, and the nested cache_creation.ephemeral_5m_input_tokens
and ephemeral_1h_input_tokens
are the breakdown. Depending on older SDK versions or models, there's no breakdown and only the total comes back. When the breakdown is zero and there's a total, a fallback treats the full amount as 5-minute cache (unit price $18.75/MTok).
The (usage.get("cache_creation", {}) or {})
form exists to convert the case where the cache_creation
key exists but is null (None
) into an empty dict via or {}
. None.get(...)
raises an AttributeError.
When I installed the hook and submitted my first prompt, nothing happened. No error either. It took 30 minutes to track down the cause.
Claude Code hooks are launched as a kind of subprocess — neither a login shell nor an interactive shell. The nvm-derived PATH configured in ~/.zshrc
isn't inherited. Type which ccusage
in a terminal and you get a full path back, but in the hook's execution environment command -v ccusage
comes back empty.
The fallback on line 19 addresses this.
CCUSAGE=$(command -v ccusage 2>/dev/null || echo "$HOME/.nvm/versions/node/v24.13.0/bin/ccusage")
[ -x "$CCUSAGE" ] || exit 0
When command -v
fails, hardcode nvm's known path. This got the hook working. However, nvm's version number is environment-dependent, so it'll break again on another machine or a future upgrade. Values hardcoded into a script need periodic verification.
macOS has no timeout
command. Installing GNU coreutils gives you gtimeout
, but Homebrew's install location differs between M1 and Intel. /usr/local/bin/gtimeout
and /opt/homebrew/bin/gtimeout
coexist.
At first I hardcoded TIMEOUT_BIN="/opt/homebrew/bin/gtimeout"
. When I tried the hook on another machine, it only existed at Intel's /usr/local/bin/
, so ccusage ran without a timeout. During a network outage it blocked for 40 seconds, not 5.
The current code searches with command -v gtimeout
, tries Homebrew's path if not found, and empties it if that isn't executable either. Running without a timeout isn't ideal, but the judgment call is that it beats the hook not working at all. Since it writes the cache and exits 0 on failure, even a worst-case 40-second stall still lets the next prompt through.
In the initial implementation, I only wrote the cache when a warning fired.
if [ -n "$WARN" ]; then
printf '%b' "$WARN" > "$CACHE"
cat "$CACHE" >&2
fi
No warning (the normal case) meant no cache update. As a result, while below the thresholds, ccusage ran on every single prompt. The 60-second throttle did absolutely nothing.
I discovered it because terminal responsiveness was visibly slow for several days during rapid-fire work. I ran time cost_guard.sh
just to check and it was taking 300–400ms. Suspecting the cache wasn't functioning, I looked at timestamps with ls -la /tmp/cost_guard_*.cache
and found it wasn't being updated in the normal case.
The fix was adding one line.
printf '%b' "$WARN" > "$CACHE"
[ -s "$CACHE" ] && cat "$CACHE" >&2
printf '%b' "$WARN" > "$CACHE"
creates an empty file even when WARN
is empty. [ -s "$CACHE" ]
(file size greater than 0) evaluates false so nothing is printed, but the timestamp alone gets updated. That timestamp is what the next stat -f %m
hits, and the 60-second cache works.
This wasn't a problem for a while, but it broke when I tried to test the hook in a CI environment (Ubuntu).
age=$(( $(date +%s) - $(stat -f %m "$CACHE" 2>/dev/null || echo 0) ))
In Linux's GNU stat, -f
is the flag for "display filesystem information," and the format specifier is -c
. Typing stat -f %m
on Linux gives completely different output, or an error. 2>/dev/null || echo 0
returns 0, and the age calculation becomes $(date +%s) - 0
. The current Unix seconds (e.g. 1750000000) minus 0 clearly exceeds CACHE_AGE (60 seconds), so the cache never hits.
The practical impact is "on Linux the cache doesn't work and ccusage runs every time." No error surfaces. It degrades silently.
I decided to leave 2>/dev/null || echo 0
as-is. "Return 0 on failure and invalidate the cache" is a fallback in the safe direction. If Linux support becomes necessary, options include branching on uname
or replacing it with python3 -c "import os; print(int(os.path.getmtime('$CACHE')))"
. For now I've accepted it as macOS-only.
Line 1 of cost_guard.sh has set -u
.
set -u
It's the setting that makes referencing an undefined variable exit 1. Combined with the TIMEOUT_BIN=""
handling, this caused an unexpected problem.
In the initial implementation, I didn't define TIMEOUT_BIN
up front — I assigned it as the result of a conditional. In environments where gtimeout is found it gets assigned; in environments where it isn't, the variable stayed undefined. Referencing an undefined variable in the ${TIMEOUT_BIN:+...}
expansion triggers set -u and exits 1, and on top of that the || { : > "$CACHE"; exit 0; }
fail-open handling doesn't run either, so it terminates without writing the cache.
The same thing happens on the next prompt: ccusage runs, fails again, and the cache never gets created — forever.
The fix is adding explicit empty initialization.
TIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN="" # ← ここで必ず定義済みにする
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
assigns an empty string "if not executable," guaranteeing the variable is defined. From then on ${TIMEOUT_BIN:+...}
expands safely.
stop_cost_log.sh using set -uo pipefail
— stricter than set -u
— comes from the same thinking. But the Stop hook demands accuracy over immediacy, and its direction is "give up on the record if a command fails," so pipefail rarely gets in the way.
The first week after I added stop_cost_log.sh, cost-log.jsonl stayed nearly empty and didn't grow. When I set CC_COST_DEBUG=1
to enable the debug log (~/.claude/logs/stop_cost_log.log
) and ran it, the error read_error: 'utf-8' codec can't decode byte 0xe2 in position ...
was recorded.
The cause was that one session's transcript contained bytes that were invalid as UTF-8. Most likely from a session force-killed in the middle of code completion. Reading that transcript raised an exception and except Exception as e: log(f"read_error: {e}"); sys.exit(0)
exited right there. In other words, the cost of a session that used thousands of tokens finished unrecorded.
The fix is changing the errors mode on open.
with open(tp, "r", encoding="utf-8") as f:
with open(tp, "r", encoding="utf-8", errors="replace") as f:
With errors="replace"
, invalid byte sequences are substituted with U+FFFD (?) and reading continues. The usage on a corrupted line might be slightly off, but that's vastly better than losing the entire session. Since this fix, no records have gone missing.
This isn't an implementation bug — it's a misreading of the warning text.
WARN="${WARN}⚠ [cost-guard] burn rate \$${BURN_INT}/hr (API equiv) — Opus 連投。"
The burn rate ccusage computes on the MAX plan is a conversion of "how much this would cost per hour if you were hitting the API directly," not an amount actually billed to you. The first time I saw this warning I panicked: "I'm on MAX, why is this costing over $100 an hour?"
I added the (API equiv)
note later to prevent that confusion. The $80/hr figure from hammering Opus is an accurate rule of thumb in practice, and when it appears, switching to Haiku 4.5 (claude --model haiku
) makes a perceptible difference in the headroom you have until the next block. As long as you understand what the warning means, the number is genuinely useful.
I dissected seven pitfalls above, but in actual operation I've hit more than ten. Below is a bulleted sweep, focused on the items not detailed above.
① Forgetting chmod +x, and the hook is silently ignored
I created the hook file and didn't set the execute bit right afterward. Even if ~/.claude/hooks/cost_guard.sh
exists, Claude Code won't call the hook — without any error — unless it's -rwxr-xr-x
. Always verify with ls -la ~/.claude/hooks/*.sh
. Putting chmod +x ~/.claude/hooks/*.sh
in your dotfile setup script prevents a snag when migrating to a new machine.
② jq not installed, and all processing gets skipped
jq isn't in macOS by default. In an environment where brew install jq
hasn't been done, ACTIVE=$(echo "$BLOCK_JSON" | jq ... 2>/dev/null)
comes back empty and the guard [ -z "$ACTIVE" ] && { : > "$CACHE"; exit 0; }
exits right there. A quiet failure with no warning and no error. Verify with command -v jq
immediately after installing the hook.
③ Multiple Claude Code sessions share the same cache
CACHE="/tmp/cost_guard_${USER}.cache"
is shared across all Claude Code sessions on the machine. When you're running Claude Code in two terminals in parallel, one calling ccusage resets the other's 60-second timer. The cost information itself is fine to share across sessions, so there's essentially no real harm, but if you need per-session isolation, make it /tmp/cost_guard_${USER}_$PPID.cache
.
④ A hook exiting with exit 1 blocks the prompt
Stepping on an undefined variable under set -u
gives exit 1. Claude Code is designed to detect hook failure (exit 1) and stop accepting prompts. That's exactly why cost_guard.sh is rigorous about ending with exit 0
on every path it takes. The || { : > "$CACHE"; exit 0; }
form appearing everywhere is an explicit priority: give up on both the warning and the cost measurement, and let the prompt through.
⑤ A ccusage version change altered JSON fields and it stopped silently
ccusage is an external tool, and major version upgrades can change JSON field names. If .blocks[].tokenCounts.outputTokens
changes, OUT_TOK
becomes empty, the integer comparison never crosses the threshold, and warnings stop appearing. Once a month, check the raw JSON with ccusage blocks --active --json | jq '.'
and eyeball whether the script's jq paths match the actual fields.
⑥ Burn rate is always 0 on non-MAX plans
The burnRate.costPerHour
returned by ccusage blocks --active --json
is a conversion premised on MAX plan 5-hour block consumption. On API billing plans it doesn't return a meaningful value; BURN_HR
is always 0 and the Opus-hammering warning doesn't function. That's why the script header explicitly states "MAX定額なので $ 自体は気にしない" (on MAX flat rate, don't worry about the $ itself). Accept it as MAX-plan-only and use it that way.
⑦ cost-log.jsonl's line count balloons over months
The JSONL format of one line per session exceeds 3,600 lines a year at 10 sessions a day. The line count itself is small, but when I later wrote a script to aggregate everything with jq -s
, speed became a problem once it hit tens of thousands of lines. Decide early whether to split files monthly like cost-log-2026-09.jsonl
or move to SQLite.
⑧ Leaving CC_COST_DEBUG=1 set, and the debug log balloons
stop_cost_log.sh
's debug log keeps writing to ~/.claude/logs/stop_cost_log.log
when CC_COST_DEBUG=1
is set. Forget to unset it after debugging and lines get appended every time a session ends. Check with echo $CC_COST_DEBUG
and explicitly run unset CC_COST_DEBUG
if you don't need it.
⑨ Left the hardcoded nvm version number alone for six months
Line 19's "$HOME/.nvm/versions/node/v24.13.0/bin/ccusage"
hardcodes the Node.js version number. Switch to a new version with nvm install
and that path no longer exists, [ -x "$CCUSAGE" ] || exit 0
fails open, and it stops silently. I've had the experience of taking a long time to notice "the hook isn't running" after an nvm upgrade. When you change nvm use
, check ls ~/.nvm/versions/node/
and update the relevant line in the script.
⑩ STDERR output isn't visible in an IDE terminal
cat "$CACHE" >&2
outputs the warning to STDERR. In Claude Code's CLI, hook STDERR is displayed directly above the prompt, but via the VS Code or JetBrains extension it can flow into a different panel or be suppressed. If you think "I installed the hook but no warnings appear," first run bash ~/.claude/hooks/cost_guard.sh < /dev/null
directly in a terminal and check whether STDERR is visible.
⑪ The 500k/800k thresholds go stale when the MAX plan spec changes
The numbers 500000
(past half the block) and 800000
(end of block) are hardcoded in the script. Anthropic sometimes changes the 5-hour block output token ceiling, and when that happens the warning timing drifts unless you manually update the script. Making it overridable via environment variable, like COST_GUARD_WARN_HIGH="${COST_GUARD_WARN_HIGH:-800000}"
, makes maintenance easier.
I've distilled 14 principles from the implementation and its many failures.
1. Make fail-open (exit 0) the default for hooks
A hook failing and blocking the prompt is the worst-case scenario. External command not installed, network down, JSON parse failure — design every error path to land on exit 0
. A missing warning is vastly better than Claude Code stopping. cost_guard.sh
exits 0 on all of: ccusage not installed, gtimeout not installed, jq failure, and timeout.
2. Always attach a timeout to external commands
Set a 5-second cap, as in ${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5} "$CCUSAGE" blocks --active --json
. When ccusage doesn't respond due to a network outage or API rate limiting, without a timeout it blocks for 40+ seconds. If gtimeout isn't found it runs without a cap, but even that lands via || { : > "$CACHE"; exit 0; }
.
3. Write the cache in the normal case (no warning) too
printf '%b' "$WARN" > "$CACHE" # WARN が空でも実行
[ -s "$CACHE" ] && cat "$CACHE" >&2
Even when WARN
is empty, > "$CACHE"
updates the file's timestamp. Without it, ccusage keeps running on every prompt while you're below the cost thresholds. "Recording normal" is the core of the cache.
4. Write command lookup as a discovery → hardcode fallback chain
Hooks run in an environment that isn't an interactive shell, where ~/.zshrc
's PATH configuration has no effect. Always include a fallback that tries nvm's known path when command -v ccusage
fails. If that isn't executable either, bail out with exit 0
. This three-stage structure prevents PATH-dependent snags.
5. If you use set -u, always initialize variables explicitly
Assign an empty string like TIMEOUT_BIN=""
to make the variable defined. If you don't initialize a variable that gets produced as the result of a conditional, under set -u
even the ${TIMEOUT_BIN:+...}
expansion exits 1. The "empty it if not executable" pattern [ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
is safe.
6. Prevent nulls in numeric fields with jq's alternative operator // 0
OUT_TOK=$(echo "$ACTIVE" | jq -r '.tokenCounts.outputTokens // 0')
Even when the JSON field is absent or null, // 0
returns 0. Passing null
into an integer comparison is an error. Put // 0
on every numeric-extraction line and downstream comparisons become safe.
7. Strip floats with %. before passing them to integer comparison*
"500000.0"
. Delete everything after the dot with ${OUT_TOK%.*}
before handing it to bash's -ge
. Add 2>/dev/null
at the end of the comparison as further insurance, so even an empty, null, or N/A OUT_TOK
silences the error and the if evaluates false.8. When wrapping Python in Bash, pass data via env
In a python3 - <<'PY'
heredoc, stdin is used to read the code, so you can't pass data on stdin. Put it on the env with export STOP_INPUT="$INPUT"
and receive it on the Python side with os.environ.get("STOP_INPUT")
. stop_cost_log.sh's export STOP_INPUT / COST_LOG_PATH / DEBUG_LOG_PATH
are all for this reason.
9. Absorb UTF-8 errors with errors="replace" when reading files
with open(tp, "r", encoding="utf-8", errors="replace") as f:
Long-running transcripts can contain invalid byte sequences from force-kills or partial writes. With the default errors="strict"
, a UnicodeDecodeError
is raised, except
exits 0, and the entire session's record becomes zero. It's more practical to substitute invalid bytes with U+FFFD via errors="replace"
and keep reading.
10. For cache pricing, check both the breakdown fields and the total field
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's API returns both cache_creation_input_tokens
(the total) and its nested breakdown (ephemeral_5m
/ephemeral_1h
). On older SDKs and models there may be no breakdown, only the total. Without checking both and having a fallback, cost calculations for past sessions go wrong.
11. Use real-time estimates (flash report) and actual records (confirmed report) for different purposes
The burn rate cost_guard.sh
emits is ccusage's estimate (API-equivalent). Use it as a feel for "how much am I using right now." When you need accurate cost, aggregate ~/.claude/logs/cost-log.jsonl
with jq -s '[.[].cost_usd] | add'
. Don't conflate the natures of the two data sources.
12. Always state the burn rate's unit in the warning text
WARN="⚠ [cost-guard] burn rate \$${BURN_INT}/hr (API equiv) — Opus 連投"
Without the (API equiv)
note, a MAX plan user misreads it as "a $100/hour bill is coming." Convey the difference between the MAX plan and API billing plans in the warning text itself. Comments in code don't get read, but a parenthetical in a warning hits your eyes every time.
13. Make thresholds and durations overridable via environment variable
Writing it as CACHE_AGE="${COST_GUARD_CACHE_AGE:-60}"
lets you adjust without recompiling, to match MAX plan spec changes or personal preference. It becomes possible to run sessions where you want 30 seconds and sessions where 120 is fine.
14. Always route the DEBUG flag through env inside the stop hook, controlled from outside
stop_cost_log.sh enables the debug log when CC_COST_DEBUG=1
is passed in from outside. Designing it to be env-controlled rather than hardcoding the flag inside the script saves you the hassle of "editing a file every time you debug" and also prevents debug log leakage in production.
In one sentence, cost_guard.sh
's design is "resolving the speed-vs-accuracy tradeoff with a 60-second cache window."
Call ccusage on every prompt and you always know the latest state, but 300–400ms of latency wrecks the UX. Don't call it and no warning ever arrives. Return the cache on re-runs within 60 seconds, and call the external command only when 60 seconds have passed — this third option gets you both hook autonomy and performance.
One line that reads a timestamp with stat -f %m
. One line that conditionally inserts a timeout with ${TIMEOUT_BIN:+"$TIMEOUT_BIN" 5}
. One line that updates the cache in the normal case too with printf '%b' "$WARN" > "$CACHE"
. Each looks unremarkable at a glance, but drop any one of them and the whole thing breaks. When you read a hook, ask of each line "which error scenario is this handling?" and the design intent becomes visible.
stop_cost_log.sh
stands on the opposite side of this design. Speed is unnecessary; only accuracy is required. It reads all of transcript.jsonl, computes with per-model API rates, and appends to JSONL. Only with both the flash report and the confirmed report do you get visibility into both "where am I right now" and "what did I use this month."
Since building this mechanism, work almost never stalls from burning a 5-hour block down to the end. When the Opus-hammering warning appears and I switch to Haiku 4.5 (claude --model haiku
), the headroom until the next block changes perceptibly. Half of the ¥1.2M/month was achieved at the stage where I stopped hammering Opus and shifted light work onto Haiku — that's how much model selection affects throughput.
The general pattern for safely embedding a heavy hook is this: "attach a timeout to the external command, write an empty cache and exit 0 on failure, write the cache in the normal case too." Follow these three principles and you can safely embed any external command into a hook. Beyond a cost sentry, the same pattern is reusable for Slack notifications, Git branch state checks, automated test result checking, and more.
I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*