A Blank Dashboard and a Fake 2.5M-Token Reading: Building a Token Fuel Gauge for Claude Code A developer built a token fuel gauge for Claude Code to monitor usage in real time, preventing quality degradation from hitting the 5-hour block limit. The system combines ccusage and cost logs into a status-line indicator that warns users before they approach the cap. The developer reports that this automation helped grow a side hustle to ¥1.2M a month in revenue. A side hustle I started in college went from ¥100k a month to ¥600k once I was running several at the same time. A layoff took it to zero. Then I spent six months building an autonomous setup around Claude Code, and today it runs at ¥1.2M a month in revenue. The core of that setup isn't "writing code" — it's stacking up mechanisms that let me notice things before they fall apart . This post opens up the complete wiring of one of them: a "token fuel gauge" that warns me before a 5-hour block burns out. Claude Code MAX plan has usage blocks that reset every 5 hours. The problem is that consumption is invisible from the outside . There's no remaining-capacity bar like the browser version has. When you're running a Claude Code session in a terminal, nothing on screen changes as you approach the ceiling of the 5-hour block. Responses don't get slower, and no error appears. It's just that output quality quietly, gradually degrades . I noticed this one night when I asked for a refactor of an automation script. Same prompt, but the output was clearly thinner than when I'd run it that morning. Parts of the code were abbreviated, and error handling had dropped out. Checking later with ccusage , that session's output tokens had already passed 800k. Right on the edge of the 800k threshold. The problem was that I had no way to know that in real time, while working. The more conscientious you are, the more you think "I'll just be more careful about how I use it." But that mindset can't beat the mechanics. The 5-hour count accumulates unconsciously, and the more focused you are, the faster it burns. I chose the opposite approach: automate the monitoring and embed the state permanently in the status line . That drops the cognitive cost of "checking how much is left" to zero. You don't have to look on purpose — it's enough that a number sits somewhere your eyes pass over. This is the same design philosophy as a fuel gauge. Nobody pops the hood and measures the oil level every time they drive. There's a gauge on the dashboard, so a glance is enough to make a judgment. Claude Code's 5-hour block just needs the same structure. Here are the three things we build in this article. token-budget-advisor.sh ccusage and cost-log.jsonl — and emits a three-level verdict 🟢 ok / 🟡 warn / 🔴 critical --short mode dashboard.sh Once it's done, every time you open a terminal you'll see something like this in the status line. budget: 🟢 OK 5h:312k tok $1.2 / 7d:$48 Or the color changes automatically as you approach the ceiling. budget: 🟡 burst 5h:843k tok $3.1 / 7d:$92 budget: 🔴 cap-near 5h:1231k tok $4.8 / 7d:$134 The moment that enters your field of vision, the decision to "push the heavy work into the next block" becomes natural. This system picks up information from two data sources and consolidates them into a single script. ┌─────────────────────────────────────────────────────┐ │ データソース層 │ │ │ │ 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 │ └─────────────────────┘ This is an important implementation decision. ccusage blocks --json is active-block data output by Claude Code's official CLI tool. It reflects the token count of the currently running block most accurately. However, you can't get data in environments where ccusage isn't installed, or when no block is active. ~/.claude/logs/cost-log.jsonl is the cost log Claude Code generates automatically. For each combination of session ID and transcript, it records the cumulative cost and output token count. Since this doesn't depend on ccusage , it always works as a fallback.The script's implementation has this priority order. 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 '|' ... It pulls out only the active block and receives outputTokens and costUSD pipe-delimited. Then, when combining with the aggregation result from cost-log.jsonl , the ccusage values take priority lines 127–131 of the code . 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 The result of this design is a fallback structure that works even if either data source is missing . If ccusage isn't usable, aggregation runs on cost-log.jsonl alone and the script exits 0 fail-open policy . This file has one trap: multiple lines are recorded for the same session . Because Claude Code writes to the log incrementally during a session, many intermediate aggregates remain from before the final token count was settled. Naively summing all lines causes double counting. The "only take the last line" logic below avoids that lines 100–111 of the code . 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 It builds a dictionary keyed on the session id, transcript pair and keeps overwriting whenever a line has a newer timestamp. By the time the loop ends, what's left in latest is only the final settled value for each session/transcript. The values extracted through this aggregation are what feed the three-level threshold check. There are four thresholds lines 139–142 of the code . THRESH 5H WARN = 800 000 output tokens THRESH 5H CRIT = 1 200 000 THRESH WEEK WARN = 3000 USD THRESH SESS PER DAY = 5 Output tokens in the 5-hour block go to warn past 800k and critical past 1.2M . Weekly cost goes to warn past $3,000 . On top of that, if the session average over the last 3 days exceeds 5 per day, a burst flag is raised. 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 functions as a "burnout forecast." Even when the absolute token count is still under the threshold, a high session frequency means consumption is that much faster. It works as a leading signal: you're fine now, but there's a good chance you'll cross into warn before the night is out. --short mode and folding it into dashboard.sh The detailed JSON output is handy while debugging, but it's far too long to embed in a status line. Pass the --short argument and you get a one-line summary. result = { ... " short": f"{icon} {label} 5h:{out 5h/1000:.0f}k tok ${cost 5h:.1f} / 7d:${cost 7d:.0f} ", } The icon evaluates the 5-hour status with top priority lines 173–181 of the code . 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" Running in --short mode gives output like this. 🟢 OK 5h:312k tok $1.2 / 7d:$48 dashboard.sh pulls that single line in like so line 79 of the script . echo " 💰 Cost 7d " ~/.claude/scripts/cost-summary.sh --short echo " budget: $ ~/.claude/scripts/token-budget-advisor.sh --short " dashboard.sh runs daily via cron and auto-updates ~/.claude/dashboard.md . In other words, the next time you open the dashboard, yesterday's fuel-consumption summary has already been written into it . The morning after a heavy session, one look at the dashboard tells you "yesterday went all the way to warn." Real-time status-line integration is covered in more detail in the next chapter. set -u A single line at the top of the script packs in the whole design philosophy. set -u -e は外す: fail-open 方針 Dropping -e exit immediately on error is intentional. On line 79, dashboard.sh calls the script inside a command substitution like this. echo " budget: $ ~/.claude/scripts/token-budget-advisor.sh --short " If a subcommand inside a command substitution exits 1, under set -e the calling shell itself dies. dashboard.sh runs every morning via cron and updates several sections at once — Health, Cost, Hook latency, and more. Having the entire dashboard go blank every time token-budget-advisor.sh dies from a ccusage path mismatch or a missing log is a problem. So I set up 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" In --short mode it prints ⚫ n/a and finishes with exit 0 . The dashboard shows budget: ⚫ n/a , but the state "data couldn't be fetched" remains on the page as text. That's far easier to debug than a silent blank. 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 There are three layers. Layer 1 : existence check with command -v ccusage /dev/null 2 &1 . In a launchd environment, PATH is only /usr/bin:/bin:/usr/sbin:/sbin , so ccusage under nvm isn't visible. Skipping here means nothing after it is touched at all. Layer 2 : ccusage blocks --json 2 /dev/null || true . This covers the case where ccusage exists but spits out some error bad JSON, network problems . || true guarantees exit 0, and CC JSON becomes an empty string. Layer 3 : python3 -c "..." 2 /dev/null || echo "|" . Even if the Python parse fails, it returns the fallback string | . Because the following bash parameter expansions "${EXTRACTED%| }" and "${EXTRACTED |}" split on the pipe delimiter, a bare | makes both empty strings, which is treated the same as ccusage not being used. The reason for splitting with parameter expansion instead of using something like python3 -m json.tool is to shave off one subshell. If this gets embedded in a status line, the call frequency could get high, so I stack up small efficiencies. isdigit check The bash→Python bridge goes through 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:-} is the pattern for expanding an undefined variable to an empty string under set -u . In environments where ccusage isn't installed, CC OUTPUT TOK stays undefined, so without this the script dies with unbound variable . cc out str.isdigit rejects empty strings, decimals, negative values, and the string None all in one shot. Passing an empty string to int raises ValueError , so you'd need try/except — but for an integer check, isdigit fits in one line. cc cost is handled with try/except ValueError because ccusage returns decimals like "0.001234" . Reading the code, cost-log.jsonl gets opened twice. There's a first pass and a second pass. 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 The first pass is now essentially dead code. It builds sess 7d by day , but downstream it's the by day Counter updated in the second pass that actually gets used. It's leftover code from the implementation process. What's effective is the latest dictionary in the second pass lines 100–124 of the code . 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 Keyed on session id, transcript , it keeps overwriting whenever a line's timestamp is newer. After the loop ends, iterating latest.items walks only the final settled value for each session/transcript. Why this is necessary : because Claude Code writes JSONL incrementally during a session. Every time the same transcript in the same session grows "8,000 → 18,400 → 29,700 → 44,100 tokens," a line is appended with the cumulative value at that point. Naively summing all lines gives 8,000+18,400+29,700+44,100 = 100,200, but the correct consumption is the final value, 44,100. The result dictionary has a source diff pct field. 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, ... } It doesn't appear in --short mode, but it's included in the detailed JSON output. It records, as a percentage, the divergence between the ccusage-derived token count and the cost-log.jsonl-derived one. If this keeps exceeding 20%, that's a sign that one of the data sources is broken or that ccusage's data structure has changed. In normal operation you never see it, but when something feels off about the numbers, running token-budget-advisor.sh manually no arguments prints the detailed JSON, and this value tells you which source to suspect. short format The advice field joins everything together when multiple flags are raised lines 161–171 of the code . 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" When "5h is warn AND weekly is also warn AND burst" overlap, advice lists three items separated by slashes. Grepping the detailed-JSON-mode logs afterward tells you how often those compound states occur. The short format rounds to thousands with {out 5h/1000:.0f}k tok line 196 of the code . " short": f"{icon} {label} 5h:{out 5h/1000:.0f}k tok ${cost 5h:.1f} / 7d:${cost 7d:.0f} ", :.0f displays an integer with the decimals truncated. 312000 → 312k reads much better. For cost display, the 5h figure has one decimal place and the weekly one is an integer, evening out the visual information density. The first version had set -eo pipefail in it. One morning I opened ~/.claude/dashboard.md and the contents were empty. The mtime was from that morning, but the file size was 0 bytes. launchd jobs only have /usr/bin:/bin:/usr/sbin:/sbin on PATH. ccusage, installed via nvm, lives at ~/.nvm/versions/node/v24.13.0/bin/ccusage , which isn't on the path in a launchd environment. ccusage blocks --json returned exit 127 with command not found , and under -e the script died instantly. dashboard.sh 's command substitution $ token-budget-advisor.sh --short propagated that exit code, the whole redirect block {...} "$OUT" was cancelled, and OUT became 0 bytes. The fix came in two steps. 修正前 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 は空文字 Ending fail open with exit 0 is the design I derived from this experience. There are still days when the single line budget: ⚫ n/a shows up on the dashboard, but that's meaningful information — "there was a day ccusage couldn't be read" — and it's far easier to debug than a blank page. The first implementation didn't use the latest dictionary; it just summed every line. 危険な初期実装 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 全行合算 One night, after a long stretch of heavy work, the --short output showed 🔴 cap-near 5h:2541k tok... . The threshold is 1.2M, so 2.5M is physically impossible. It exceeds the MAX plan's ceiling. Opening cost-log.jsonl directly, there were 30-plus lines with the same session id reading "output": 11200 , "output": 23800 , "output": 39500 , and so on. I'd been adding up every cumulative value Claude Code writes incrementally during a session. After fixing it to group by session id, transcript and take only the last line, the same session read 🟡 burst 5h:843k tok... . That was the correct number. This experience confirmed that the output field in cost-log.jsonl is a cumulative value, not a delta . You can't guess that from the filename — it's a bug that only surfaces once you run it against real data. I'd forgotten to add ensure ascii=False to the Python output. 危険な初期実装 print json.dumps result ensure ascii=False なし Here's the kind of string that came out of --short mode. 🟢 OK 5h:312k tok $1.2 / 7d:$48 The 🟢 U+1F7E2 had become a surrogate-pair escape. Print that to a terminal and, depending on how zsh handles the string, you either get \ud83d displayed literally as characters, or the prompt-width calculation goes off and the cursor position breaks. 修正後 print json.dumps result, ensure ascii=False Python 3's default is ensure ascii=True escaping non-ASCII characters as \uXXXX . Japanese advice strings break the same way. ensure ascii=False is a mandatory specification for JSON serialization that handles emoji or Japanese. ccusage blocks --json comes back with a structure like this. { "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 } } At first I wasn't filtering on isActive and was summing outputTokens across all blocks. 危険な初期実装 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 になる It added in past blocks too, so it always came out critical. The fix pulls out only the active block lines 41–47 of the code . 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 '|' The doubled {} in tc = b.get 'tokenCounts', {} or {} is also worth a look. When tokenCounts comes back as null right after a block starts, for instance , get returns None . None or {} becomes {} , so the following .get "outputTokens", 0 doesn't crash. A get default alone can't prevent the null → None case, so or {} is necessary. set -u I originally wrote the --short mode check like this. 危険な初期実装 if "$1" = "--short" ; then MODE="--short" fi set -u exits 1 immediately when an undefined variable is referenced. Calling token-budget-advisor.sh with no arguments produced the error $1: unbound variable and died. 修正後 MODE="${1:-json}" ${1:-json} uses json as the default value when $1 is undefined or empty. A no-argument call becomes MODE=json and passing --short becomes MODE=--short , which coexists with set -u . Since I also aligned the subsequent checks to "$MODE" = "--short" , every reference to $1 disappeared from the script. Small defenses like this are bugs you don't notice until "it suddenly dies in production cron." Most of these stumbles only surfaced by "building a version that runs first, then running it against real files." Even if you verify the threshold logic with unit tests, you can't catch the cost-log.jsonl double-counting problem until you feed it a real file. The launchd PATH problem doesn't reproduce until you register it with cron and run it for the first time. There are bugs you can only see with real data and a real environment. The structure that keeps you from leaving those to "it should work" — fail-open, ⚫ n/a in --short , the source diff pct debug info — stacked up, and now the daily dashboard runs without ever going blank. To build "a mechanism that notices before the environment falls apart," you first crush every place you personally got stuck. That's the unglamorous core of maintaining a ¥1.2M/month autonomous setup. The previous chapter went through five stumbles in detail with real code blank dashboard, the 2.5M-token anomaly, broken emoji, no isActive filter, the set -u no-argument crash . Here I'll list the additional gotchas I actually hit, in bullet form. These are mostly ones that surfaced after going into operation. Forgetting the single quotes on the heredoc EOF The main aggregation section embeds the Python script in bash with a <<'PY' heredoc. At first I wrote <