My Cost Monitor Said $234 When the Real Bill Was $48. Then set -e Made It Go Silent for a Week. A developer's cost-monitoring script for Claude Code silently failed twice, once overcounting weekly spend by 4-6x and once going quiet due to set -e, leading to a redesign that prioritizes fail-open behavior so the dashboard never goes silent. The 212-line bash script now displays 'n/a' on errors instead of terminating, ensuring cost anomalies remain visible. A monitoring script that dies quietly is worse than no monitoring at all. Mine proved it twice: first it reported a 7-day spend of $234 when ccusage daily said the real number was $48 a 4–6x overcount from double-summing cumulative log lines, now down to a set -euo pipefail , a single ccusage timeout made the whole thing exit 1 and my status bar sat critical without me noticing. The fix in both cases was the same design decision: ⚫ n/a , and exit 0. This post walks through the one script — 212 lines — that runs my cost monitoring today, and the design principle behind it: never let the dashboard go quiet.I went from ¥100k/month as a university student juggling side work up to ¥600k, dropped to zero after a company-side layoff, spent six months building an autonomous Claude Code environment, and now hold ¥1.2M/month in revenue. The core of that operating base is one rule: the dashboard must never stop . This isn't really a post about dashboards. It's a post about environments. Once you start using Claude Code heavily, API cost management becomes a life-or-death issue. Even holding ¥1.2M/month in revenue, Claude Code's metered cost can blow past $3,000 in a single week if you take your eye off it. In the autonomous environment I built, launchd fires a cost-monitoring script every 30 minutes and the result is rendered into my status bar and terminal dashboard. The problem is this: if the monitoring script dies, the whole dashboard dies with it. set -euo pipefail looks robust. It gets recommended as a shell scripting best practice all the time. But the moment the 5-hour-window token calculation trips partway through, the status bar goes blank. The moment ccusage doesn't respond over the network, the launchd job terminates with an error. The moment the first automated run fires before cost-log.jsonl exists, the script dies on an exception. That's the structural problem: the happy path is all green; what breaks is the error paths and the passage of time. Design it fail-closed — meaning set -e turns every error into script termination — and the dashboard goes silent on all of those error paths. Silence looks like "no problems." That's the worst possible UI. You lose the ability to distinguish normal operation from missing data. Design it fail-open, and the error paths still display ⚫ n/a . "No data" and "healthy" look different. You glance at the dashboard and immediately know something is off. That's the crux of dashboard design. The property you need from a monitoring script isn't accuracy. It's never going quiet. When you're mass-producing personal projects as a side business, an autonomous environment running alongside AI is a productivity multiplier. But if that environment itself isn't monitored , it keeps running while broken. You blow past a $3,000 weekly threshold with the cost anomaly invisible. A fail-open monitoring script is your environment's autoimmune system. I suspect most readers stop at "I wrote a script and it runs." I did too. But once you hit the mass-production phase, "it worked at first and then broke without me noticing" happens constantly. Nothing is more harmful than broken monitoring — that's the angle here. ~/.claude/scripts/token-budget-advisor.sh is a 212-line bash script that calls Python3 internally — a mixed-language setup. The file looks long, but the structure is simple. token-budget-advisor.sh │ ├─ 前処理 set -u のみ -e は外す・fail-open方針 │ ├─ データ源① ccusage blocks --json ← 公式カウント 優先 │ │ │ └─ 取得失敗 → CC OUTPUT TOK="" のまま続行 fail-open │ ├─ データ源② $HOME/.claude/logs/cost-log.jsonl ← 自前ログ │ │ │ └─ ファイル不在 → fail open → exit 0 │ ├─ 集計 Python3 heredoc │ ├─ 5hウィンドウ: session dedup + ccusage優先マージ │ ├─ 7dウィンドウ: weekly cost集計 │ └─ 直近3d burst判定 avg 5 sess/day │ ├─ 判定 🟢 OK / 🟡 warn / 🔴 critical │ └─ 出力 ├─ --short モード → 1行 "🟢 OK 5h:XXXk tok $X.X / 7d:$XXX " └─ JSON モード → 整形済みJSONオブジェクト(全フィールド) The key point is that each layer fails open independently . If ccusage can't be read, it proceeds to the Python aggregation. If the Python aggregation comes up empty, it exits through fail open . Whichever layer breaks, the design guarantees it doesn't go silent. Lines 15–27 at the top of the script condense the entire design philosophy. set -u -e は外す: fail-open 方針 LOG="$HOME/.claude/logs/cost-log.jsonl" MODE="${1:-json}" 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 } The reason for dropping set -e is stated in a one-line comment: "fail-open 方針" fail-open policy . That's the declaration of design intent. fail open takes an error-reason string as an argument. In --short mode it prints a single line, ⚫ n/a ; in JSON mode it emits a minimal JSON object containing 5h status:"unknown" and weekly status:"unknown" , then terminates with exit 0 . Because it exits zero, both launchd and cron treat it as a normal termination. The dashboard shows ⚫ n/a , and a human instantly understands "some data isn't being collected." This function gets called in three places. 1. Log file missing line 29 : -f "$LOG" || fail open "cost-log.jsonl not found" Day one of setup, or when the log path changes. The existence check lives only here. 2. Python aggregation comes up empty lines 203–205 : if -z "$RESULT" ; then fail open "python aggregation failed" fi For when Python throws an error to stderr and leaves stdout empty. Since 2 /dev/null discards the error output, all bash learns is "aggregation failed." 3. JSON parse failure line 208 : python python3 -c "import sys,json; print json.load sys.stdin ' short' " 2 /dev/null || fail open "json parse failed" For when Python emits malformed JSON. The || falls through to fail open . In every case, fail open guards only the points where "if this trips, downstream output can't be guaranteed." This isn't defensive programming that catches every error — it's a design that explicitly protects the minimal set of chokepoints where a failure means no output at all. The data sources are a two-stage setup lines 34–56 . 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 '|' except Exception: print '|' " 2 /dev/null || echo "|" CC OUTPUT TOK="${EXTRACTED%| }" CC COST 5H="${EXTRACTED |}" fi fi ccusage blocks --json 2 /dev/null || true — throwing errors into /dev/null and falling back to true keeps the pipeline from stopping. Even in an environment where ccusage doesn't exist, processing continues with CC OUTPUT TOK and CC COST 5H as empty strings. Inside the inline Python script, try/except Exception swallows all exceptions and prints | just the separator on failure. After splitting EXTRACTED , you get CC OUTPUT TOK="" and CC COST 5H="" , and the rest of the processing treats it as "no ccusage." When ccusage is alive, its values take priority over the self-log aggregates as the "official" numbers lines 126–131 . 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 On top of that, the source diff pct field computes the divergence rate against the self-computed aggregate lines 135–137 and includes in the JSON how far apart the two data sources are. 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 This is for debugging, but it's also early detection for bugs in the self-log aggregation logic. In fact, when this source diff pct exceeded 20%, I discovered a session double-counting bug in cost-log.jsonl . The --short mode, meant for dashboard integration, narrows output to a single line. The actual output format is defined at line 196 of the Python heredoc. " short": f"{icon} {label} 5h:{out 5h/1000:.0f}k tok ${cost 5h:.1f} / 7d:${cost 7d:.0f} ", For example, healthy looks like 🟢 OK 5h:342k tok $1.2 / 7d:$48 , and a warning looks like 🟡 burst 5h:823k tok $4.1 / 7d:$1204 . The format assumes it's called by launchd every 30 minutes and embedded in the terminal status bar. The icon decision logic is concentrated in lines 173–181. 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" The actual threshold values live at lines 139–141. 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 a 5-hour block over 800,000 turns it 🟡; over 1,200,000 turns it 🔴. Weekly cost over $3,000 turns it 🟡. If the average session count over the last 3 days exceeds 5, a "focused work" flag is raised. That detection isn't a separate alert — it's folded into the advice string line 169 . if burst: advice parts.append f"直近3d平均 {avg sess:.1f}sess/day: 集中作業中" JSON mode formats output through python3 -m json.tool line 210 . It's for manual inspection and for piping into other scripts. --short targets launchd's automated invocation, JSON targets a human checking manually — that separation of roles is the essence of the two-mode design. When --short returns ⚫ n/a , running JSON mode by hand puts the error reason in the advice field. {"5h status":"unknown","weekly status":"unknown","advice":"python aggregation failed"} Even the debugging path from dashboard to JSON mode is self-contained in those two modes. latest dict The meatiest part of the script is the Python aggregation block at lines 80–124. It reads the file once and throws it away, then reads it again. Let's start with why it's two-pass. The spec of cost-log.jsonl is: "for each session ID, transcript file pair, cumulative values are progressively overwritten." While Claude Code keeps running within the same session, the running token total up to that moment is appended as a JSONL line every 10 minutes. In other words, if you just sum every line in the file, you add the same cost dozens of times over. My first naive implementation did exactly that. A session whose real cost was $0.8 ballooned to $12 — one multiple per log line. The corrected code looks like this lines 99–111 . 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 Note that the key is a session id, transcript tuple. If you key on session id alone, a different transcript file generated when Claude Code restarts gets overwritten as "the same session." That actually wiped out an entire day of cost data once. Keying on the pair of both fields gives you the right granularity: "the latest state of the same work context." After building this dict, it loops with for sid, tr , t, r in latest.items line 113 , deciding whether the timestamp falls within 5h or 7d and aggregating accordingly. The two-pass structure exists because the requirement "use only the latest line" can't be satisfied in one pass. To aggregate while reading in a streaming fashion, you'd first have to scan everything to pin down the final line for each key. The first loop, still sitting at lines 80–97, is nearly empty. with open log path as f: for line in f: try: r = json.loads line t = datetime.datetime.fromisoformat r "ts" except Exception: continue sid = r.get "session id", "" ... if t = cutoff 5h: pass ← ここが空 It literally says pass . That's the part where the comment reads "if the transcript is the same we want to overwrite-aggregate with the latest line → simple summing is fine here." The trace of me hesitating mid-rewrite is still sitting there. In reality, the 5-hour window aggregation is also done from the latest dict in the second pass lines 116–119 . The first pass currently only updates sess 7d by day , and even that dict isn't used for the final burst determination the by day counter is used instead — the more you read, the more visible the "refactor stopped halfway" evidence becomes. This isn't a bug; it's a judgment call that there was no need to break working code. The script runs correctly at 212 lines. The implementation that "composes ccusage data and self-log aggregates with a priority order" is just 6 lines, at 126–131. 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 Stashing the original value in own out 5h is the important part — it's there so that when diff pct is computed at lines 134–136, there's something to compare the ccusage value against. 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 The source diff pct field is only visible in JSON mode. You don't normally think about it, but when a bug creeps into the self-log logic, you notice the instant it diverges from ccusage by 20% or more. I've found two bugs that way. In environments where ccusage isn't usable e.g. command -v ccusage fails due to a PATH issue , cc out and cc cost both stay None , the priority handling is skipped, and it runs on self-log aggregates alone. Because the "stages" of the two-stage fallback are cleanly separated, you can also confirm which source was used via the ccusage used field line 194 . The way Python is launched at line 59 is slightly unusual. RESULT=$ python3 - "$LOG" "${CC OUTPUT TOK:-}" "${CC COST 5H:-}" <<'PY' 2 /dev/null python3 - is the mode that reads a script from stdin. After that, <<'PY' pipes a heredoc into stdin. Arguments are passed via sys.argv . Why not split it out into a file? For the simplicity of placement: "everything is contained in one script." When I copy this script to another environment, I don't have to separately verify that the Python file exists. Drop one file into ~/.claude/scripts/ and it works. The launchd plist configuration just points at that single path. The important detail is that the heredoc delimiter is wrapped in single quotes as <<'PY' . With an unquoted <