{"slug": "a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for", "title": "A Blank Dashboard and a Fake 2.5M-Token Reading: Building a Token Fuel Gauge for Claude Code", "summary": "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.", "body_md": "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.\n\nClaude Code (MAX plan) has usage blocks that reset every 5 hours. The problem is that **consumption is invisible from the outside**.\n\nThere'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**.\n\nI 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`\n\n, that session's output tokens had already passed 800k. Right on the edge of the 800k threshold.\n\nThe problem was that I had no way to know that in real time, while working.\n\nThe 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.\n\nI 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.\n\nThis 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.\n\nHere are the three things we build in this article.\n\n`token-budget-advisor.sh`\n\n`ccusage`\n\nand `cost-log.jsonl`\n\n— and emits a three-level verdict (🟢 ok / 🟡 warn / 🔴 critical)`--short`\n\nmode`dashboard.sh`\n\nOnce it's done, every time you open a terminal you'll see something like this in the status line.\n\n```\nbudget: 🟢 OK (5h:312k tok $1.2 / 7d:$48)\n```\n\nOr the color changes automatically as you approach the ceiling.\n\n```\nbudget: 🟡 burst (5h:843k tok $3.1 / 7d:$92)\nbudget: 🔴 cap-near (5h:1231k tok $4.8 / 7d:$134)\n```\n\nThe moment that enters your field of vision, the decision to \"push the heavy work into the next block\" becomes natural.\n\nThis system picks up information from two data sources and consolidates them into a single script.\n\n```\n┌─────────────────────────────────────────────────────┐\n│  データソース層                                       │\n│                                                     │\n│  ccusage blocks --json ──────────────────┐          │\n│  (アクティブブロックの公式出力トークン数)    │          │\n│                                           ├─► token-budget-advisor.sh\n│  ~/.claude/logs/cost-log.jsonl ──────────┘          │\n│  (session_id × transcript ごとの累積コスト)           │\n└─────────────────────────────────────────────────────┘\n                         │\n              ┌──────────▼──────────┐\n              │  判定エンジン (Python) │\n              │                     │\n              │  5h output tokens   │\n              │  ├ ≥ 1,200,000 → 🔴 critical\n              │  ├ ≥   800,000 → 🟡 warn    \n              │  └ <   800,000 → 🟢 ok      \n              │                     │\n              │  7d cost (USD)      │\n              │  └ ≥ $3,000  → 🟡 warn      \n              │                     │\n              │  直近3日 セッション数  │\n              │  └ 平均 > 5/day → burst     \n              └──────────┬──────────┘\n                         │\n              ┌──────────▼──────────┐\n              │  出力モード           │\n              │                     │\n              │  (引数なし) → JSON詳細 │\n              │  --short   → 1行サマリ │\n              └──────────┬──────────┘\n                         │\n              ┌──────────▼──────────┐\n              │  dashboard.sh       │\n              │  （日次自動更新）     │\n              │                     │\n              │  ## 💰 Cost (7d)    │\n              │    budget: [--short] │\n              └─────────────────────┘\n```\n\nThis is an important implementation decision.\n\n** 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\n\n`ccusage`\n\nisn'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\n\n`ccusage`\n\n, it always works as a fallback.The script's implementation has this priority order.\n\n```\n# ccusage が居れば 5h block の output token を取る (transcript 計算より公式)\nCC_OUTPUT_TOK=\"\"\nCC_COST_5H=\"\"\nif command -v ccusage >/dev/null 2>&1; then\n  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)\n  if [ -n \"$CC_JSON\" ]; then\n    EXTRACTED=$(printf '%s' \"$CC_JSON\" | python3 -c \"\nimport sys, json\ntry:\n    d = json.load(sys.stdin)\n    active = [b for b in d.get('blocks', []) if b.get('isActive')]\n    if active:\n        b = active[0]\n        tc = b.get('tokenCounts', {}) or {}\n        out = int(tc.get('outputTokens', 0))\n        cost = float(b.get('costUSD', 0))\n        print(f'{out}|{cost}')\n    else:\n        print('|')\n...\n```\n\nIt pulls out only the active block and receives `outputTokens`\n\nand `costUSD`\n\npipe-delimited. Then, when combining with the aggregation result from `cost-log.jsonl`\n\n, the ccusage values take priority (lines 127–131 of the code).\n\n```\n# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)\nown_out_5h = out_5h\nif cc_out is not None and cc_out > 0:\n    out_5h = cc_out\nif cc_cost is not None and cc_cost > 0:\n    cost_5h = cc_cost\n```\n\nThe 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).\n\nThis 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.\n\nThe \"only take the last line\" logic below avoids that (lines 100–111 of the code).\n\n```\n# cost-log は session_id × transcript ごとに累積値で書かれる仕様。\n# 最新行のみ採用するため、(session_id, transcript) で最終行を取り直す。\nlatest = {}\nwith open(log_path) as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n            t = datetime.datetime.fromisoformat(r[\"ts\"])\n        except Exception:\n            continue\n        key = (r.get(\"session_id\", \"\"), r.get(\"transcript\", \"\"))\n        prev = latest.get(key)\n        if (prev is None) or (t > prev[0]):\n            latest[key] = (t, r)\n```\n\nIt builds a dictionary keyed on the `(session_id, transcript)`\n\npair and keeps overwriting whenever a line has a newer timestamp. By the time the loop ends, what's left in `latest`\n\nis only the final settled value for each session/transcript.\n\nThe values extracted through this aggregation are what feed the three-level threshold check.\n\nThere are four thresholds (lines 139–142 of the code).\n\n```\nTHRESH_5H_WARN      = 800_000    # output tokens\nTHRESH_5H_CRIT      = 1_200_000\nTHRESH_WEEK_WARN    = 3000       # USD\nTHRESH_SESS_PER_DAY = 5\n```\n\nOutput 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.\n\n```\nif out_5h >= THRESH_5H_CRIT:\n    s5 = \"critical\"\nelif out_5h >= THRESH_5H_WARN:\n    s5 = \"warn\"\nelse:\n    s5 = \"ok\"\n\n# 集中作業判定: 直近 3 日で平均 > 5 sess/day\nrecent_days = sorted(by_day.keys())[-3:]\navg_sess = sum(by_day[d] for d in recent_days) / max(1, len(recent_days))\nburst = avg_sess > THRESH_SESS_PER_DAY\n```\n\n`burst`\n\nfunctions 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.\n\n`--short`\n\nmode and folding it into dashboard.sh\nThe detailed JSON output is handy while debugging, but it's far too long to embed in a status line. Pass the `--short`\n\nargument and you get a one-line summary.\n\n```\nresult = {\n    ...\n    \"_short\": f\"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})\",\n}\n```\n\nThe icon evaluates the 5-hour status with top priority (lines 173–181 of the code).\n\n```\nif s5 == \"critical\":\n    icon, label = \"🔴\", \"cap-near\"\nelif s5 == \"warn\" or sw == \"warn\":\n    icon, label = \"🟡\", \"burst\"\nelif burst:\n    icon, label = \"🟡\", \"burst\"\nelse:\n    icon, label = \"🟢\", \"OK\"\n```\n\nRunning in `--short`\n\nmode gives output like this.\n\n```\n🟢 OK (5h:312k tok $1.2 / 7d:$48)\n```\n\n`dashboard.sh`\n\npulls that single line in like so (line 79 of the script).\n\n```\necho \"## 💰 Cost (7d)\"\n~/.claude/scripts/cost-summary.sh --short\necho \"  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)\"\n```\n\n`dashboard.sh`\n\nruns daily via cron and auto-updates `~/.claude/dashboard.md`\n\n. 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.\"\n\nReal-time status-line integration is covered in more detail in the next chapter.\n\n`set -u`\n\nA single line at the top of the script packs in the whole design philosophy.\n\n```\nset -u  # -e は外す: fail-open 方針\n```\n\nDropping `-e`\n\n(exit immediately on error) is intentional. On line 79, `dashboard.sh`\n\ncalls the script inside a command substitution like this.\n\n```\necho \"  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)\"\n```\n\nIf a subcommand inside a command substitution exits 1, under `set -e`\n\nthe calling shell itself dies. `dashboard.sh`\n\nruns 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`\n\ndies from a ccusage path mismatch or a missing log is a problem.\n\nSo I set up `fail_open()`\n\n.\n\n```\nfail_open() {\n  if [ \"$MODE\" = \"--short\" ]; then\n    echo \"⚫ n/a\"\n  else\n    printf '{\"5h_status\":\"unknown\",\"weekly_status\":\"unknown\",\"advice\":\"%s\"}\\n' \"${1:-no data}\"\n  fi\n  exit 0\n}\n\n[ -f \"$LOG\" ] || fail_open \"cost-log.jsonl not found\"\n```\n\nIn `--short`\n\nmode it prints `⚫ n/a`\n\nand finishes with `exit 0`\n\n. The dashboard shows `budget: ⚫ n/a`\n\n, but the state \"data couldn't be fetched\" remains on the page as text. That's far easier to debug than a silent blank.\n\n```\nif command -v ccusage >/dev/null 2>&1; then\n  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)\n  if [ -n \"$CC_JSON\" ]; then\n    EXTRACTED=$(printf '%s' \"$CC_JSON\" | python3 -c \"\n...\n\" 2>/dev/null || echo \"|\")\n    CC_OUTPUT_TOK=\"${EXTRACTED%|*}\"\n    CC_COST_5H=\"${EXTRACTED#*|}\"\n  fi\nfi\n```\n\nThere are three layers.\n\n**Layer 1**: existence check with `command -v ccusage >/dev/null 2>&1`\n\n. In a launchd environment, PATH is only `/usr/bin:/bin:/usr/sbin:/sbin`\n\n, so ccusage under nvm isn't visible. Skipping here means nothing after it is touched at all.\n\n**Layer 2**: `ccusage blocks --json 2>/dev/null || true`\n\n. This covers the case where ccusage exists but spits out some error (bad JSON, network problems). `|| true`\n\nguarantees exit 0, and `CC_JSON`\n\nbecomes an empty string.\n\n**Layer 3**: `python3 -c \"...\" 2>/dev/null || echo \"|\"`\n\n. Even if the Python parse fails, it returns the fallback string `|`\n\n. Because the following bash parameter expansions `\"${EXTRACTED%|*}\"`\n\nand `\"${EXTRACTED#*|}\"`\n\nsplit on the pipe delimiter, a bare `|`\n\nmakes both empty strings, which is treated the same as ccusage not being used.\n\nThe reason for splitting with parameter expansion instead of using something like `python3 -m json.tool`\n\nis 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.\n\n`isdigit()`\n\ncheck\nThe bash→Python bridge goes through `sys.argv`\n\n.\n\n```\nRESULT=$(python3 - \"$LOG\" \"${CC_OUTPUT_TOK:-}\" \"${CC_COST_5H:-}\" <<'PY' 2>/dev/null\nimport sys, json, datetime, collections\n\nlog_path, cc_out_str, cc_cost_str = sys.argv[1], sys.argv[2], sys.argv[3]\ncc_out = int(cc_out_str) if cc_out_str.isdigit() else None\ntry:\n    cc_cost = float(cc_cost_str) if cc_cost_str else None\nexcept ValueError:\n    cc_cost = None\n```\n\n`${CC_OUTPUT_TOK:-}`\n\nis the pattern for expanding an undefined variable to an empty string under `set -u`\n\n. In environments where ccusage isn't installed, `CC_OUTPUT_TOK`\n\nstays undefined, so without this the script dies with `unbound variable`\n\n.\n\n`cc_out_str.isdigit()`\n\nrejects empty strings, decimals, negative values, and the string `None`\n\nall in one shot. Passing an empty string to `int()`\n\nraises `ValueError`\n\n, so you'd need try/except — but for an integer check, `isdigit()`\n\nfits in one line. `cc_cost`\n\nis handled with `try/except ValueError`\n\nbecause ccusage returns decimals like `\"0.001234\"`\n\n.\n\nReading the code, cost-log.jsonl gets opened twice. There's a first pass and a second pass.\n\n```\n# 1パス目\nwith open(log_path) as f:\n    for line in f:\n        ...\n        if t >= cutoff_5h:\n            pass  # ← 実際には何もしない\n        if t >= cutoff_7d:\n            day = t.strftime(\"%Y-%m-%d\")\n            sess_7d_by_day[day].add(sid)\n```\n\nThe first pass is now essentially dead code. It builds `sess_7d_by_day`\n\n, but downstream it's the `by_day`\n\nCounter (updated in the second pass) that actually gets used. It's leftover code from the implementation process.\n\nWhat's effective is the `latest`\n\ndictionary in the second pass (lines 100–124 of the code).\n\n```\nlatest = {}\nwith open(log_path) as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n            t = datetime.datetime.fromisoformat(r[\"ts\"])\n        except Exception:\n            continue\n        key = (r.get(\"session_id\", \"\"), r.get(\"transcript\", \"\"))\n        prev = latest.get(key)\n        if (prev is None) or (t > prev[0]):\n            latest[key] = (t, r)\n\nfor (sid, _tr), (t, r) in latest.items():\n    out = int(r.get(\"output\", 0))\n    cost = float(r.get(\"cost_usd\", 0))\n    if t >= cutoff_5h:\n        out_5h  += out\n        cost_5h += cost\n        n_5h    += 1\n```\n\nKeyed on `(session_id, transcript)`\n\n, it keeps overwriting whenever a line's timestamp is newer. After the loop ends, iterating `latest.items()`\n\nwalks only the final settled value for each session/transcript.\n\n**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.\n\nThe `result`\n\ndictionary has a `source_diff_pct`\n\nfield.\n\n```\ndiff_pct = None\nif cc_out is not None and own_out_5h > 0:\n    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)\n\nresult = {\n    ...\n    \"source_diff_pct\": diff_pct,\n    \"ccusage_used\": cc_out is not None,\n    ...\n}\n```\n\nIt doesn't appear in `--short`\n\nmode, 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.\n\nIf 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`\n\nmanually (no arguments) prints the detailed JSON, and this value tells you which source to suspect.\n\n`_short`\n\nformat\nThe `advice`\n\nfield joins everything together when multiple flags are raised (lines 161–171 of the code).\n\n```\nadvice_parts = []\nif s5 == \"critical\":\n    advice_parts.append(f\"5h output {out_5h/1000:.0f}k超過: 一旦休憩推奨\")\nelif s5 == \"warn\":\n    advice_parts.append(f\"5h output {out_5h/1000:.0f}k接近: 重い作業は次ブロックへ\")\nif sw == \"warn\":\n    advice_parts.append(f\"7d cost ${cost_7d:.0f}: MAX定額枠の消費過多\")\nif burst:\n    advice_parts.append(f\"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中\")\nif not advice_parts:\n    advice_parts.append(\"budget healthy\")\n```\n\nWhen \"5h is warn AND weekly is also warn AND burst\" overlap, `advice`\n\nlists three items separated by slashes. Grepping the detailed-JSON-mode logs afterward tells you how often those compound states occur.\n\nThe `_short`\n\nformat rounds to thousands with `{out_5h/1000:.0f}k tok`\n\n(line 196 of the code).\n\n```\n\"_short\": f\"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})\",\n```\n\n`:.0f`\n\ndisplays an integer with the decimals truncated. `312000 → 312k`\n\nreads 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.\n\nThe first version had `set -eo pipefail`\n\nin it.\n\nOne morning I opened `~/.claude/dashboard.md`\n\nand the contents were empty. The mtime was from that morning, but the file size was 0 bytes.\n\nlaunchd jobs only have `/usr/bin:/bin:/usr/sbin:/sbin`\n\non PATH. ccusage, installed via nvm, lives at `~/.nvm/versions/node/v24.13.0/bin/ccusage`\n\n, which isn't on the path in a launchd environment. `ccusage blocks --json`\n\nreturned exit 127 with `command not found`\n\n, and under `-e`\n\nthe script died instantly.\n\n`dashboard.sh`\n\n's command substitution `$( token-budget-advisor.sh --short )`\n\npropagated that exit code, the whole redirect block `{...} > \"$OUT\"`\n\nwas cancelled, and OUT became 0 bytes.\n\nThe fix came in two steps.\n\n```\n# 修正前\nset -eo pipefail\n...\nCC_JSON=$(ccusage blocks --json)  # ccusage がなければ exit 127 → 即死\n\n# 修正後\nset -u  # -e を外す\n...\nCC_JSON=$(ccusage blocks --json 2>/dev/null || true)  # 失敗しても exit 0、CC_JSON は空文字\n```\n\nEnding `fail_open()`\n\nwith `exit 0`\n\nis the design I derived from this experience. There are still days when the single line `budget: ⚫ n/a`\n\nshows 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.\n\nThe first implementation didn't use the `latest`\n\ndictionary; it just summed every line.\n\n```\n# 危険な初期実装\nwith open(log_path) as f:\n    for line in f:\n        r = json.loads(line)\n        t = datetime.datetime.fromisoformat(r[\"ts\"])\n        if t >= cutoff_5h:\n            out_5h += int(r.get(\"output\", 0))  # 全行合算\n```\n\nOne night, after a long stretch of heavy work, the `--short`\n\noutput showed `🔴 cap-near (5h:2541k tok...)`\n\n. The threshold is 1.2M, so 2.5M is physically impossible. It exceeds the MAX plan's ceiling.\n\nOpening cost-log.jsonl directly, there were 30-plus lines with the same `session_id`\n\nreading `\"output\": 11200`\n\n, `\"output\": 23800`\n\n, `\"output\": 39500`\n\n, and so on. I'd been adding up every cumulative value Claude Code writes incrementally during a session.\n\nAfter fixing it to group by `(session_id, transcript)`\n\nand take only the last line, the same session read `🟡 burst (5h:843k tok...)`\n\n. That was the correct number.\n\nThis 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.\n\nI'd forgotten to add `ensure_ascii=False`\n\nto the Python output.\n\n```\n# 危険な初期実装\nprint(json.dumps(result))  # ensure_ascii=False なし\n```\n\nHere's the kind of string that came out of `--short`\n\nmode.\n\n```\n🟢 OK (5h:312k tok $1.2 / 7d:$48)\n```\n\nThe 🟢 (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`\n\ndisplayed literally as characters, or the prompt-width calculation goes off and the cursor position breaks.\n\n```\n# 修正後\nprint(json.dumps(result, ensure_ascii=False))\n```\n\nPython 3's default is `ensure_ascii=True`\n\n(escaping non-ASCII characters as `\\uXXXX`\n\n). Japanese advice strings break the same way. `ensure_ascii=False`\n\nis a mandatory specification for JSON serialization that handles emoji or Japanese.\n\n`ccusage blocks --json`\n\ncomes back with a structure like this.\n\n```\n{\n  \"blocks\": [\n    { \"isActive\": true, \"tokenCounts\": { \"outputTokens\": 412000 }, \"costUSD\": 1.52 },\n    { \"isActive\": false, \"tokenCounts\": { \"outputTokens\": 980000 }, \"costUSD\": 3.61 },\n    { \"isActive\": false, \"tokenCounts\": { \"outputTokens\": 542000 }, \"costUSD\": 2.01 }\n  ]\n}\n```\n\nAt first I wasn't filtering on `isActive`\n\nand was summing `outputTokens`\n\nacross all blocks.\n\n```\n# 危険な初期実装\nd = json.load(sys.stdin)\nout = sum(b.get(\"tokenCounts\", {}).get(\"outputTokens\", 0) for b in d.get(\"blocks\", []))\n# → 412000 + 980000 + 542000 = 1,934,000 になる\n```\n\nIt added in past blocks too, so it always came out critical.\n\nThe fix pulls out only the active block (lines 41–47 of the code).\n\n```\nactive = [b for b in d.get('blocks', []) if b.get('isActive')]\nif active:\n    b = active[0]\n    tc = b.get('tokenCounts', {}) or {}\n    out = int(tc.get('outputTokens', 0))\n    cost = float(b.get('costUSD', 0))\n    print(f'{out}|{cost}')\nelse:\n    print('|')\n```\n\nThe doubled `{}`\n\nin `tc = b.get('tokenCounts', {}) or {}`\n\nis also worth a look. When `tokenCounts`\n\ncomes back as `null`\n\n(right after a block starts, for instance), `get()`\n\nreturns `None`\n\n. `None or {}`\n\nbecomes `{}`\n\n, so the following `.get(\"outputTokens\", 0)`\n\ndoesn't crash. A `get()`\n\ndefault alone can't prevent the `null`\n\n→ `None`\n\ncase, so `or {}`\n\nis necessary.\n\n`set -u`\n\nI originally wrote the `--short`\n\nmode check like this.\n\n```\n# 危険な初期実装\nif [ \"$1\" = \"--short\" ]; then\n  MODE=\"--short\"\nfi\n```\n\n`set -u`\n\nexits 1 immediately when an undefined variable is referenced. Calling `token-budget-advisor.sh`\n\nwith no arguments produced the error `$1: unbound variable`\n\nand died.\n\n```\n# 修正後\nMODE=\"${1:-json}\"\n```\n\n`${1:-json}`\n\nuses `json`\n\nas the default value when `$1`\n\nis undefined or empty. A no-argument call becomes `MODE=json`\n\nand passing `--short`\n\nbecomes `MODE=--short`\n\n, which coexists with `set -u`\n\n.\n\nSince I also aligned the subsequent checks to `[ \"$MODE\" = \"--short\" ]`\n\n, every reference to `$1`\n\ndisappeared from the script. Small defenses like this are bugs you don't notice until \"it suddenly dies in production cron.\"\n\nMost 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.\n\nThere 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`\n\nin `--short`\n\n, the `source_diff_pct`\n\ndebug info — stacked up, and now the daily dashboard runs without ever going blank.\n\nTo 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.\n\nThe previous chapter went through five stumbles in detail with real code (blank dashboard, the 2.5M-token anomaly, broken emoji, no `isActive`\n\nfilter, the `set -u`\n\nno-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.\n\n**Forgetting the single quotes on the heredoc EOF**\n\nThe main aggregation section embeds the Python script in bash with a `<<'PY'`\n\nheredoc. At first I wrote `<<PY`\n\n(no quotes). Do that and bash expands variables inside the document. For instance, even if you've written `log_path = sys.argv[1]`\n\nin your Python code, the moment a `$HOME`\n\nappears inside the heredoc, bash replaces it with the home directory path. The script works syntactically, but you discover the problem — a hardcoded path — when you run it on a different machine. The single quotes in `<<'PY'`\n\ndisable bash's variable expansion completely. The rule in this script is to unify bash→Python value passing on `sys.argv`\n\nalone, so there's no need whatsoever for variables inside the heredoc.\n\n**How the first pass became dead code**\n\nReading the actual script, cost-log.jsonl gets opened twice (the first pass on lines 80–96, and the second pass on lines 100–111). Inside the first pass is this comment.\n\n```\nif t >= cutoff_5h:\n    # transcript 同一の場合は最新行で上書き集計したい → ここは単純合算で OK\n    # (cost-log は session ごとに累積値で書かれているので、最新行のみ採用すべき)\n    pass\n```\n\nIt's `pass`\n\n. It does nothing. I initially tried to do \"take only the last line\" in a single pass, but you can't know \"whether this line is the last one\" until you read the next line. To keep overwriting during a scan, \"last\" isn't settled until you've read the whole file. So a second pass became necessary, and the first pass was left with only the code that aggregates session counts into `sess_7d_by_day`\n\n. But what ultimately gets used is the `by_day`\n\nCounter updated in the second pass, and `sess_7d_by_day`\n\nisn't used either. The evolution of the implementation is left in the code as a fossil.\n\n**A timezone naive/aware collision silently skips every line**\n\nIf cost-log.jsonl's `ts`\n\nfield is in a timezone-bearing format like `2026-08-02T05:12:33+00:00`\n\n, `datetime.datetime.fromisoformat(r[\"ts\"])`\n\nreturns a tz-aware `datetime`\n\n. Meanwhile, the aggregation reference time is computed like this.\n\n```\nnow = datetime.datetime.now()\ncutoff_5h  = now - datetime.timedelta(hours=5)\n```\n\n`datetime.now()`\n\nis tz-naive. In the `t >= cutoff_5h`\n\ncomparison, naive and aware collide, and on Python below 3.11 you get `TypeError: can't compare offset-naive and offset-aware datetimes`\n\n. But because this code sits inside `try/except Exception: continue`\n\n, the exception never reaches the console and the line is simply skipped. If every line is skipped, `out_5h=0`\n\nstays as-is and processing finishes with a normal value (zero tokens) rather than `cost-log.jsonl not found`\n\n. The output becomes `🟢 OK (5h:0k tok $0.0 / 7d:$0)`\n\n— the hardest bug to notice, appearing as the phenomenon \"for some reason the cost is zero.\"\n\n**An assumption about launchd job names broke**\n\nLine 38 of `dashboard.sh`\n\nhas this code.\n\n```\nlaunchctl list | grep com.shun | awk '{printf \"- %s exit=%s\\n\", $3, $2}' | head -15\n```\n\nIt's a grep that assumes launchd jobs are created with the `com.shun.*`\n\nnaming convention. Jobs created with a different convention don't show up at all. There were days when the dashboard's \"Scheduled Jobs\" section showed only one entry, and I misread it as \"the jobs are gone.\" In reality the grep pattern just didn't match the job names. Since launchctl's listing puts the canonical job name in the `Label`\n\ncolumn, you need to change the pattern to match your own environment's job naming convention.\n\n**Single quotes collide inside python3 -c**\n\nThe ccusage parsing section (lines 37–52 of the script) uses the form `printf '%s' \"$CC_JSON\" | python3 -c \"...\"`\n\n. The reason you can use Python single quotes inside `\"...\"`\n\nis that the outer quoting is double quotes.\n\n``` python\nEXTRACTED=$(printf '%s' \"$CC_JSON\" | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nactive = [b for b in d.get('blocks', []) if b.get('isActive')]\n...\n\" 2>/dev/null || echo \"|\")\n```\n\nThe single quotes in `d.get('blocks', [])`\n\ndon't terminate the bash string. That's because I chose the approach of passing JSON via stdin. Had I written `-c 'import sys...'`\n\ndirectly, the internal Python single quotes would terminate the bash string and cause a syntax error. I choose between `printf ... | python3 -c \"...\"`\n\nand `python3 - <<'PY' ... PY`\n\nbased on whether the script is short or long.\n\n**Status-line integration cost 500ms on every Enter**\n\nAt first I put the command substitution directly in zsh's `PROMPT`\n\n.\n\n```\nPROMPT='%F{blue}%~%f $(~/.claude/scripts/token-budget-advisor.sh --short) %# '\n```\n\nThe script runs every time you press Enter. Python startup (about 80ms) + reading cost-log.jsonl (50–200ms depending on line count) + the ccusage call (200–400ms) stacked up, and in sessions with heavy work the wait exceeded a perceptible 500ms. The solution is to switch to letting `dashboard.sh`\n\nhandle it. `dashboard.sh`\n\nruns daily via cron and updates `~/.claude/dashboard.md`\n\n(line 104 of dashboard.sh does `cat \"$OUT\"`\n\n). Putting a one-line command in the status line that reads that cache is much lighter. Alternatively, you can put it in tmux's `status-right`\n\nwith a 30-second update interval.\n\n**I only noticed once source_diff_pct went past 20%**\n\nIt doesn't appear in the normal `--short`\n\noutput, but running with no arguments prints a value like `\"source_diff_pct\": 23.4`\n\nin the detailed JSON.\n\n```\ndiff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)\n```\n\nIt's the divergence rate between the ccusage-derived and cost-log.jsonl-derived token counts. One day, quality felt degraded even though I shouldn't have been over the threshold. Running manually with no arguments, `source_diff_pct`\n\nwas 28.1. The cause was that ccusage's data structure had changed subtly in the previous update — a key name inside `tokenCounts`\n\nhad changed. If `source_diff_pct`\n\nis near zero (within 5%), the two sources are consistent. Since I built a habit of checking it manually on a regular basis, I've been able to catch numeric drift early.\n\nI've now walked through the implementation and all the stumbles. Here are 15 practical rules I wished I'd known from the start after actually running this.\n\n**1. Write monitoring scripts fail-open**\n\nThe worst pattern is the monitoring dying and taking the main thing with it. `fail_open()`\n\nfinishes with `exit 0`\n\nand, in `--short`\n\nmode, prints `⚫ n/a`\n\n. Since it's used in the command substitution on line 79 of `dashboard.sh`\n\n, if the advisor dies the budget line becomes `⚫ n/a`\n\n. A record saying \"the day we couldn't fetch it\" is easier to debug than a blank page. The point of fail-open isn't to swallow errors — it's to leave the state \"fetch failed\" behind as text.\n\n**2. Use only set -u and drop -e**\n\n`set -u`\n\nturns undefined variables into immediate errors and catches typos early. But adding `-e`\n\nmeans a failing external command terminates the entire script. For cron integration scripts, no `-e`\n\nis the right answer. `dashboard.sh`\n\nalso uses `set -uo pipefail`\n\n(line 3), while advisor.sh uses only `set -u`\n\n(line 15). Even if the caller has `-uo pipefail`\n\n, as long as the callee returns `exit 0`\n\n, the whole command-substitution block survives.\n\n**3. Keep two data sources so it works when either is missing**\n\nThe design lets it aggregate from cost-log.jsonl alone even in environments without ccusage. It doesn't break on a dev machine, a production machine, or a PATH-restricted launchd environment. When ccusage is available, it takes priority (lines 127–131 of the script). With a single-source dependency, the script dies every time the installation state changes.\n\n**4. Take only the last line per (session_id, transcript) key**\n\nThe `output`\n\nin cost-log.jsonl is a cumulative value, not a delta. Every time the same session grows `11200 → 23800 → 39500 → 44100`\n\n, a line is appended. Summing all lines gives 118,600, but the correct value is 44,100. Building the `latest`\n\ndictionary across two passes (lines 100–111) and using only the final settled values in the aggregation produces the correct number. Miss this and you'll always be in critical.\n\n**5. Fit integer validation in one line with isdigit()**\n\nPassing values between bash and Python lets empty strings, non-numerics, and None slip in.\n\n```\ncc_out = int(cc_out_str) if cc_out_str.isdigit() else None\n```\n\n`isdigit()`\n\nrejects empty strings, decimals, negative values, and the string `None`\n\n. Passing an empty string to `int()`\n\nraises `ValueError`\n\n, so you'd need try/except — but for an integer check, `isdigit()`\n\nfits in a single line. Decimals (ccusage's `costUSD`\n\nis a string like `\"1.524\"`\n\n) are handled with `float()`\n\n+ try/except.\n\n**6. Always pair a get() default with or {}**\n\n`b.get('tokenCounts', {})`\n\nreturns an empty dict if the key is absent, but returns `None`\n\nif the key exists and the value is `null`\n\n. A `get()`\n\ndefault alone can't prevent `null`\n\n→ `None`\n\n.\n\n```\ntc = b.get('tokenCounts', {}) or {}\n```\n\nAdding `or {}`\n\nconverts `None`\n\ninto an empty dict too. It prevents the following `.get('outputTokens', 0)`\n\nfrom throwing `AttributeError`\n\nin cases where `tokenCounts`\n\ncomes back as `null`\n\n, such as right after a block starts.\n\n**7. Never forget ensure_ascii=False**\n\nPython 3's default is `ensure_ascii=True`\n\n. Both the 🟢 emoji (U+1F7E2) and Japanese advice strings become `\\uXXXX`\n\nescapes. Cursor position shifts in terminal output, and a mystery string like `🟢`\n\nlines up in the status line. For JSON serialization containing emoji or Japanese, `json.dumps(result, ensure_ascii=False)`\n\nis a mandatory specification (line 199 of the script).\n\n**8. Make the ccusage call triple-layered**\n\n```\nif command -v ccusage >/dev/null 2>&1; then\n  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)\n  if [ -n \"$CC_JSON\" ]; then\n    EXTRACTED=$(printf '%s' \"$CC_JSON\" | python3 -c \"...\" 2>/dev/null || echo \"|\")\n```\n\nThree layers: existence check (`command -v`\n\n) → error suppression (`2>/dev/null || true`\n\n) → parse-failure fallback (`|| echo \"|\"`\n\n). If PATH in a launchd environment is only `/usr/bin:/bin:/usr/sbin:/sbin`\n\nand ccusage isn't visible, layer 1 skips it. If ccusage exists but the JSON is malformed, layer 2 catches it; if the Python parse fails, layer 3 does.\n\n**9. Pull out only active blocks with an isActive filter**\n\n`ccusage blocks --json`\n\nreturns an array that includes past blocks. Summing one active block (410k tokens) + two past blocks (1.52M tokens total) gives 1.93M, which always comes out critical.\n\n```\nactive = [b for b in d.get('blocks', []) if b.get('isActive')]\n```\n\nNarrow down to active blocks before extracting. Process only when an active block exists via `if active:`\n\n, and return the fallback with `print('|')`\n\nwhen it doesn't.\n\n**10. Consolidate --short mode and the detailed output in one script**\n\nEven though the format differs between status-line embedding and manual checking, splitting the script gives you two maintenance surfaces. When you change a threshold (800k / 1.2M / $3,000 / 5 sess/day), you update only one side and consistency breaks. Use `MODE=\"${1:-json}\"`\n\nto make no-argument default to JSON, and in `--short`\n\npull out only the `_short`\n\nfield (lines 207–208). Because both outputs pass through the same decision engine, numeric consistency is guaranteed.\n\n**11. Continuously record data consistency with source_diff_pct**\n\nIt doesn't appear in `--short`\n\n, but the detailed JSON output contains a value like `\"source_diff_pct\": 4.2`\n\n. It's the divergence rate between the ccusage-derived and cost-log.jsonl-derived token counts. Normally it stays within 5%. If it keeps exceeding 20%, that's a sign that ccusage's data structure changed or cost-log.jsonl's write format changed. When something feels off about the numbers, first run `token-budget-advisor.sh`\n\nmanually (no arguments) and check this value.\n\n**12. Use a single-quoted EOF for heredocs: <<'EOF'**\n\nWith `<<PY`\n\n, bash expands variables inside the heredoc. Unify bash→Python value passing on `sys.argv`\n\nand eliminate any need for bash variables inside the heredoc. With `<<'PY'`\n\n, expansion is completely disabled and Python's literal strings arrive intact.\n\n**13. Use ${VAR:-} to turn undefined variables into empty strings under set -u**\n\nIn environments without ccusage, `CC_OUTPUT_TOK`\n\nstays undefined. Referencing `\"$CC_OUTPUT_TOK\"`\n\nunder `set -u`\n\ndies with `unbound variable`\n\n. `${CC_OUTPUT_TOK:-}`\n\nturns both undefined and empty into \"empty string.\" An empty string reaches `sys.argv[2]`\n\non the Python side, `cc_out_str.isdigit()`\n\nreturns `False`\n\n, and `cc_out = None`\n\n. Rather than swallowing an error, it propagates the state \"there is no data\" in a type-safe way.\n\n**14. Route status-line embedding through a cache**\n\nPutting a command substitution directly into zsh's `PROMPT`\n\nmeans it runs on every Enter. Python startup at 80ms + the file read + the ccusage call at 200–400ms stack up into a wait exceeding 500ms during heavy work sessions. Take advantage of the design where `dashboard.sh`\n\nruns daily via cron and updates `~/.claude/dashboard.md`\n\n(line 104 of dashboard.sh), and keep the status line to a one-line command that reads the cache file. Putting it in tmux's `status-right`\n\nwith a 30-second update interval also works.\n\n**15. Decide thresholds only after observing 1–2 weeks of real data**\n\nThe numbers `THRESH_5H_WARN = 800_000`\n\n/ `THRESH_5H_CRIT = 1_200_000`\n\nweren't fixed from the start. For one to two weeks I manually checked the detailed JSON output of `token-budget-advisor.sh`\n\n(no arguments), confirmed that output density perceptibly thins past 800k tokens, and only then adopted them as thresholds. The optimal values change with your own work patterns. If you're mostly asking light questions, the 5-hour block often resets naturally before you enter warn. The $3,000 weekly cost ceiling is also a number tuned to how I use the MAX plan's flat rate. The order matters: run it first, observe, then decide the numbers.\n\n`token-budget-advisor.sh`\n\nis a little over 200 lines of shell script plus inline Python, but packed into it is nearly every design decision needed to \"keep a monitoring system running stably.\"\n\nThe first version I built didn't work. `set -eo pipefail`\n\nblanked the dashboard every morning, summing all lines produced a physically impossible 2.5M-token figure, and the emoji turned into escape strings.\n\nThe current implementation is the result of fixing those one by one. `fail_open()`\n\ncame from the blank-dashboard experience. The `latest`\n\ndictionary came from the 2.5M-token anomaly. `ensure_ascii=False`\n\ncame from the broken emoji. The triple-layered ccusage call came from the always-critical verdict caused by having no isActive filter. `${VAR:-}`\n\ncame from the no-argument crash. Every defense corresponds to a bug I actually hit.\n\nWhat matters in this kind of script is less \"the design while it's working\" and more \"the behavior when it breaks.\" If the monitoring system goes down, you can't notice quality degradation in what it monitors. If a single `⚫ n/a`\n\nline comes out when it breaks, it remains as information: \"we couldn't get data today.\" That's completely different from a blank page.\n\nJust adding one line to line 79 of `dashboard.sh`\n\nmeans yesterday's fuel-consumption summary gets written into the morning dashboard automatically.\n\n```\necho \"  budget: $(~/.claude/scripts/token-budget-advisor.sh --short)\"\n```\n\nThe cognitive cost of checking token headroom mid-work went to zero. Instead of quality degrading without my noticing and me realizing the next morning that \"yesterday's code looks sketchy,\" the decision to push heavy work into the next 5-hour block comes naturally.\n\nA ¥1.2M/month autonomous setup runs not on flashy AI features but on an accumulation of unglamorous instruments like this one.\n\nI've put the whole picture of the setup, the breakdown of the ¥1.2M, and a 30-day procedure into a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for", "canonical_source": "https://dev.to/bokuwalily/a-blank-dashboard-and-a-fake-25m-token-reading-building-a-token-fuel-gauge-for-claude-code-36m6", "published_at": "2026-08-25 11:00:04+00:00", "updated_at": "2026-08-25 11:14:10.564509+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["Claude Code", "ccusage"], "alternates": {"html": "https://wpnews.pro/news/a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for", "markdown": "https://wpnews.pro/news/a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for.md", "text": "https://wpnews.pro/news/a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for.txt", "jsonld": "https://wpnews.pro/news/a-blank-dashboard-and-a-fake-2-5m-token-reading-building-a-token-fuel-gauge-for.jsonld"}}