#!/usr/bin/env node // Claude Code statusline (Node). Reads session JSON from stdin, prints one line. // // · · · · <5h bar> · <7d bar> · · · // // Cost shows turn (Δ since last user prompt), session, and month-to-date. // Month-to-date is a cross-session ledger: each render adds the session's new // spend (delta) into the current month's bucket (~/.claude/.cc-cost-ledger.json). // // Must NEVER throw — a crash blanks the bar. Every optional part is its own // try/catch. Schema: https://code.claude.com/docs/en/statusline.md "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const crypto = require("crypto"); const { execFileSync } = require("child_process"); // ─── CONFIG ────────────────────────────────────────────────────────────────── const CONFIG = { sep: " · ", // segment padding (single-space around the dot — tight) barWidth: 8, // usage bar cells contextWarnPct: 70, contextCritPct: 90, usageAlways: true, usageWarnPct: 50, usageCritPct: 80, costStyle: "icon", // "slash" → $0.15/turn | "icon" → bright $0.15 + dim trailing glyph gitCacheTtlMs: 8000, // git status spawn is ~400ms cold on win32 — cache harder extraTtlMs: 120000, cmdTimeoutMs: 2500, ledgerPruneDays: 40, // burn rate ($/hr): recent-window spend rate, decay-proof (idle samples age out) burnWindowMs: 10 * 60000, // sliding window of cost samples to rate over burnMinSpanMs: 60000, // need ≥ this much elapsed inside window before showing burnMaxSamples: 60, // cap stored samples per session (keeps ledger small) // burn-rate color: truecolor gradient stops ($/hr). grounded in this ledger // (median ~$9.5, p90 ~$19, peak intervals ~$50) + Opus 4.8 pricing ($5/$25/MTok): // ~$8 calm single-stream · ~$18 heavy/active · ~$36+ parallel-agent burn. // smooth lerp between stops — these are gradient anchors, not hard tiers. burnCool: 8, // ≤ this → pure green (calm) burnWarn: 18, // amber midpoint (≈ p90 — notably elevated) burnHot: 36, // ≥ this → pure red (HOT — deep tail) burnBold: 28, // ≥ this → bold weight (visual alarm) }; const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); // ─── ANSI ──────────────────────────────────────────────────────────────────── const E = "\x1b"; const R = `${E}[0m`; const c = { cyan: (s) => `${E}[36m${s}${R}`, magenta: (s) => `${E}[35m${s}${R}`, green: (s) => `${E}[32m${s}${R}`, yellow: (s) => `${E}[33m${s}${R}`, red: (s) => `${E}[31m${s}${R}`, blue: (s) => `${E}[34m${s}${R}`, dim: (s) => `${E}[2m${s}${R}`, white: (s) => `${E}[97m${s}${R}`, accent: (s) => `${E}[93m${s}${R}`, // bright yellow — warm badge accent (no orange in the 16-index palette) }; const lerp = (a, b, t) => Math.round(a + (b - a) * t); // single channel const mix = (from, to, t) => from.map((v, i) => lerp(v, to[i], t)); // 3-channel lerp // Shared color basis. Base colors are ANSI 16-index (38;5;N, N<16) so the terminal THEME // remaps them on dark↔light. A gradient between two indices can't itself be an index, so // it's lerp'd in truecolor from each index's reference RGB — only the transient middle of // a ramp is non-adaptive; every anchor follows the theme. const IDX = { calm: 12, warn: 13, crit: 9 }; // bars: blue → magenta → red (all theme-adaptive) const BURN = { cool: 2, warm: 3, hot: 1 }; // 🔥 burn: green → yellow → red (kept distinct from bars) const IDX_RGB = { 1: [205, 0, 0], 2: [0, 205, 0], 3: [205, 205, 0], 9: [255, 0, 0], 13: [255, 0, 255] }; // xterm refs — lerp blends only const sgr = (code, s) => `${E}[${code}m${s}${R}`; const idxColor = (n, bold) => (s) => sgr(`${bold ? "1;" : ""}38;5;${n}`, s); // adaptive anchor const rgbColor = (rgb, bold) => (s) => sgr(`${bold ? "1;" : ""}38;2;${rgb[0]};${rgb[1]};${rgb[2]}`, s); // blend // 🔥 burn-rate color: green (calm) → yellow → red (HOT, bold past burnBold). anchors are // indices; the warming middle is a truecolor blend. render gates on burnRate>0 (no NaN). function burnColor(rate, label) { const { burnCool, burnWarn, burnHot, burnBold } = CONFIG; const bold = rate >= burnBold; if (rate <= burnCool) return idxColor(BURN.cool, bold)(label); if (rate >= burnHot) return idxColor(BURN.hot, bold)(label); const rgb = rate <= burnWarn ? mix(IDX_RGB[BURN.cool], IDX_RGB[BURN.warm], (rate - burnCool) / (burnWarn - burnCool)) : mix(IDX_RGB[BURN.warm], IDX_RGB[BURN.hot], (rate - burnWarn) / (burnHot - burnWarn)); return rgbColor(rgb, bold)(label); } const trough = idxColor(IDX.calm, false); // empty cells — same adaptive index as calm, ░ glyph separates // severity → colorizer. all three anchors are ANSI indices (theme-adaptive); only the // strictly-between warn→crit blend is truecolor (lerp of the two anchors' reference RGB). function meterColor(pct, warn, crit) { if (pct < warn) return idxColor(IDX.calm, false); // calm — blue if (pct >= crit) return idxColor(IDX.crit, true); // crit — red + bold const t = (pct - warn) / (crit - warn); if (t === 0) return idxColor(IDX.warn, false); // exactly at warn — yellow return rgbColor(mix(IDX_RGB[IDX.warn], IDX_RGB[IDX.crit], t), false); // smooth blend } // fill bar with shade-density leading edge (░▒▓); color from meterColor (index-based). function bar(pct, width, warn, crit) { const p = Math.max(0, Math.min(100, pct)); const filled = (p / 100) * width; const full = Math.min(width, Math.floor(filled)); const frac = filled - full; const tip = frac > 0 && full < width ? (frac < 1 / 3 ? "░" : frac < 2 / 3 ? "▒" : "▓") : ""; const empty = Math.max(0, width - full - (tip ? 1 : 0)); return meterColor(p, warn, crit)("█".repeat(full) + tip) + trough("░".repeat(empty)); } const money = (n) => (n < 1 ? n.toFixed(3) : n.toFixed(2)); // label + 8-cell bar + pct% — shared by ctx and usage. severity drives color (subtle→loud). const meter = (label, pct, warn, crit) => `${c.dim(label)} ${bar(pct, CONFIG.barWidth, warn, crit)} ${meterColor(pct, warn, crit)(pct + "%")}`; // drop control chars (ANSI-injection / newline guard) before printing untrusted file text. const stripCtl = (s) => s.replace(/[\x00-\x1F]/g, ""); // ─── crash-safe IO helpers (all swallow — a failed read/write must never blank the bar) ── // atomic write: tmp + rename so a concurrent reader never sees a half-written file. function atomicWrite(file, data) { try { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, data); fs.renameSync(tmp, file); } catch {} } // read a small plain file as text, or null. rejects symlinks (don't follow a planted // link out of the config dir) and oversized files; lstat keeps the symlink unfollowed. function readSmallFile(file, maxSize) { try { const st = fs.lstatSync(file); if (st.isSymbolicLink() || st.size > maxSize) return null; return fs.readFileSync(file, "utf8"); } catch { return null; } } // parse a JSON file, or null on missing/corrupt. function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } // ─── stdin ─────────────────────────────────────────────────────────────────── let d = {}; try { const raw = fs.readFileSync(0, "utf8"); if (raw && raw.trim()) d = JSON.parse(raw); } catch { d = {}; } const parts = []; const push = (s) => { if (s) parts.push(s); }; const cwd = d?.workspace?.current_dir || d?.cwd; // ─── 1. dir ────────────────────────────────────────────────────────────────── try { if (cwd) push(c.cyan(path.basename(cwd) || cwd)); } catch {} // ─── 2. model ────────────────────────────────────────────────────────────────── try { // strip the " (1M context)" / any trailing parenthetical — keep just "Opus 4.8" if (d?.model?.display_name) push(c.magenta(d.model.display_name.replace(/\s*\([^)]*\)\s*$/, ""))); } catch {} // ─── 3. effort / thinking ────────────────────────────────────────────────────── try { const lvl = d?.effort?.level; if (lvl) { const on = d?.thinking?.enabled !== false; const col = lvl === "max" || lvl === "xhigh" ? c.magenta : lvl === "high" ? c.blue : c.dim; push(on ? col(lvl) : c.dim(lvl)); // no ⚡ glyph — level word alone } } catch {} // ─── 4. git / jj (smart-cached) ──────────────────────────────────────────────── push(gitOrJjSegment(cwd)); // ─── 5. context (gated) ──────────────────────────────────────────────────────── try { // used_percentage is model-aware (relative to context_window_size: 1M for extended models). // Do NOT use exceeds_200k_tokens — it's a fixed 200k threshold, so it falsely reads 100% // on a 1M-window model once a long session passes 200k tokens. const cw = d?.context_window || {}; let pct = cw.used_percentage; if (pct == null && cw.context_window_size && cw.total_input_tokens != null) { pct = (cw.total_input_tokens / cw.context_window_size) * 100; } if (pct != null) push(meter("ctx", Math.round(pct), CONFIG.contextWarnPct, CONFIG.contextCritPct)); } catch {} // ─── 6. usage bars (rate limits) ─────────────────────────────────────────────── try { const rl = d?.rate_limits; if (rl) { const add = (label, win, withReset) => { if (win && typeof win.used_percentage === "number") { const p = Math.round(win.used_percentage); if (CONFIG.usageAlways || p >= CONFIG.usageWarnPct) { let seg = meter(label, p, CONFIG.usageWarnPct, CONFIG.usageCritPct); // resets_at is UNIX EPOCH SECONDS — ×1000 to compare with Date.now() (ms) if (withReset && typeof win.resets_at === "number") { const left = win.resets_at * 1000 - Date.now(); if (left > 0) seg += c.dim("·" + fmtDuration(left)); } push(seg); } } }; add("5h", rl.five_hour, true); add("7d", rl.seven_day, false); } } catch {} // ─── 7. cost: turn · session · month ─────────────────────────────────────────── try { const total = d?.cost?.total_cost_usd; const sid = d?.session_id || "default"; if (typeof total === "number") { const { turn, month, burnRate } = updateLedger(sid, total); // 🔥 burn rate: own segment, before the cost cluster (matches bars · 🔥 · cost) if (burnRate > 0) push(burnColor(burnRate, ` $${burnRate.toFixed(2)}/hr`)); // icon style: bright $number (emphasis) + dim trailing glyph (muted icon). // turn has no glyph (bare $num); session Σ / month ☾. slash: $num/type. const fmt = (n, type, icon) => CONFIG.costStyle === "icon" ? c.white(`$${money(n)}`) + (icon ? c.dim(icon) : "") : c.white(`$${money(n)}/${type}`); const seg = []; if (turn > 0) seg.push(fmt(turn, "turn", "")); seg.push(fmt(total, "session", "Σ")); if (month > 0) seg.push(fmt(month, "month", "☾")); push(seg.join(" ")); } } catch {} // ─── 8. caveman badge (terse) ────────────────────────────────────────────────── push(cavemanBadge()); // ─── 9. extra badges (hooks: workflow, …) ────────────────────────────────────── try { const content = readSmallFile(path.join(claudeDir, ".cc-statusline-extra"), 4096); if (content) { const now = Date.now(); for (const line of content.split(/\r?\n/)) { const i = line.indexOf("|"); if (i <= 0) continue; const ts = Number(line.slice(0, i)); if (!Number.isFinite(ts) || now - ts > CONFIG.extraTtlMs) continue; const text = stripCtl(line.slice(i + 1)).slice(0, 60); if (text) push(c.accent(text)); } } } catch {} // ─── emit ────────────────────────────────────────────────────────────────────── process.stdout.write(parts.join(CONFIG.sep)); // ════════════════════════════════════════════════════════════════════════════ // cost ledger — cross-session month-to-date + per-session turn baseline // ════════════════════════════════════════════════════════════════════════════ function monthKey() { const dt = new Date(); return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}`; } function updateLedger(sid, total) { const file = path.join(claudeDir, ".cc-cost-ledger.json"); const now = Date.now(); const mk = monthKey(); let L = { month: mk, monthTotal: 0, sessions: {} }; const parsed = readJson(file); if (parsed) L = parsed; if (!L.sessions) L.sessions = {}; let dirty = false; // persist (disk write) only when something material changed // New month → reset the bucket, KEEP per-session baselines so a session that // straddles midnight only contributes its post-boundary spend. if (L.month !== mk) { L.month = mk; L.monthTotal = 0; dirty = true; } // Prune stale sessions. const cutoff = now - CONFIG.ledgerPruneDays * 864e5; for (const k of Object.keys(L.sessions)) { if ((L.sessions[k]?.ts ?? 0) < cutoff) delete L.sessions[k]; } const s = L.sessions[sid] || { total: 0, turnStart: total, ts: now }; // Turn boundary: UserPromptSubmit hook drops a marker; first render after a // prompt rebaselines turnStart, then consumes the marker. try { const marker = path.join(claudeDir, `.cc-turn-${sid}`); if (fs.existsSync(marker)) { s.turnStart = total; fs.unlinkSync(marker); dirty = true; } } catch {} // Month bucket gets only the new spend since this session was last seen. const delta = total - (s.total ?? 0); if (delta > 0) { L.monthTotal = (L.monthTotal || 0) + delta; dirty = true; } const turn = Math.max(0, total - (s.turnStart ?? total)); // Burn-rate samples: only record a new (ts,total) when spend actually moved. // Idle re-renders carry no new info and would force a needless disk write. if (!Array.isArray(s.samples)) s.samples = []; if (delta > 0) { s.total = total; s.ts = now; s.samples.push([now, total]); if (s.samples.length > CONFIG.burnMaxSamples) s.samples = s.samples.slice(-CONFIG.burnMaxSamples); } // Prune the window in-memory every render (cheap, no disk) so even an idle // render shows a current-window rate rather than a stale one. const wcut = now - CONFIG.burnWindowMs; s.samples = s.samples.filter((p) => p[0] >= wcut); const burnRate = computeBurnRate(s.samples); L.sessions[sid] = s; // Persist only when something changed — skips stringify+write+rename (the bulk // of this function's cost) on the common idle re-render path. if (dirty) { atomicWrite(file, JSON.stringify(L)); // Opportunistic GC (write-path only, so throttled to active turns): cancelled // renders leave `.cc-*.tmp` atomic-write orphans; ended sessions leave // `.cc-turn-*` markers. Sweep our own debris older than a day. try { const stale = now - 864e5; for (const f of fs.readdirSync(claudeDir)) { if (!((f.startsWith(".cc-") && f.endsWith(".tmp")) || f.startsWith(".cc-turn-"))) continue; const fp = path.join(claudeDir, f); try { if (fs.statSync(fp).mtimeMs < stale) fs.unlinkSync(fp); } catch {} } } catch {} } return { turn, month: L.monthTotal || 0, burnRate }; } // ── burn rate ($/hr) ────────────────────────────────────────────────────────── // DESIGN KNOB (yours to tune): turn timestamped cost samples into a "right now" // $/hr. Default = window endpoints — cheap, robust: spend / span across the kept // window. Stale samples are already pruned in updateLedger, so idle time drops // out naturally rather than decaying the rate toward zero (the classic // cost÷duration bug). Alternatives if you want a different feel: // • EWMA over per-sample rates → smoother, slower to react // • least-squares slope of (ts,total) → noise-resistant, a few more lines function computeBurnRate(samples) { if (!Array.isArray(samples) || samples.length < 2) return 0; const first = samples[0]; const last = samples[samples.length - 1]; const span = last[0] - first[0]; const spend = last[1] - first[1]; if (span < CONFIG.burnMinSpanMs || spend <= 0) return 0; return (spend / span) * 3600000; // $/ms → $/hr } // Compact duration: "2h45m" (minutes zero-padded) above an hour, else "45m". function fmtDuration(ms) { const m = Math.floor(ms / 60000); if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`; return `${m}m`; } // ════════════════════════════════════════════════════════════════════════════ // git / jj // ════════════════════════════════════════════════════════════════════════════ function findRepo(startDir) { try { let dir = startDir && fs.existsSync(startDir) ? startDir : process.cwd(); for (let i = 0; i < 40; i++) { if (fs.existsSync(path.join(dir, ".jj"))) return { kind: "jj", root: dir }; if (fs.existsSync(path.join(dir, ".git"))) return { kind: "git", root: dir }; const up = path.dirname(dir); if (up === dir) break; dir = up; } } catch {} return null; } function gitOrJjSegment(cwd) { let repo; try { repo = findRepo(cwd); } catch { return ""; } if (!repo) return ""; const key = crypto.createHash("sha1").update(repo.kind + ":" + repo.root).digest("hex").slice(0, 12); const cacheFile = path.join(os.tmpdir(), `cc-statusline-${key}.json`); const cached = readJson(cacheFile); if (cached && Date.now() - cached.ts < CONFIG.gitCacheTtlMs) return cached.text; let text = ""; try { text = repo.kind === "jj" ? jjSegment(repo.root) : gitSegment(repo.root); } catch { text = ""; } atomicWrite(cacheFile, JSON.stringify({ ts: Date.now(), text })); return text; } function gitSegment(root) { const out = execFileSync("git", ["-C", root, "status", "--porcelain=v2", "--branch"], { encoding: "utf8", timeout: CONFIG.cmdTimeoutMs, windowsHide: true, }); let branch = "?"; let ahead = 0; let behind = 0; let dirty = 0; for (const line of out.split(/\r?\n/)) { if (line.startsWith("# branch.head ")) branch = line.slice(14).trim(); else if (line.startsWith("# branch.ab ")) { const m = line.match(/\+(\d+)\s+-(\d+)/); if (m) { ahead = +m[1]; behind = +m[2]; } } else if (line && !line.startsWith("#")) dirty++; } if (branch === "(detached)") branch = "detached"; const col = dirty > 0 ? c.yellow : c.green; let s = col(` ${branch}`); // U+E0A0 powerline branch glyph (Nerd Font) if (dirty > 0) s += c.yellow(`●${dirty}`); if (ahead) s += c.cyan(`↑${ahead}`); if (behind) s += c.cyan(`↓${behind}`); return s; } function jjSegment(root) { const tmpl = 'change_id.shortest(8) ++ "|" ++ bookmarks ++ "|" ++ if(empty, "clean", "dirty")'; const out = execFileSync( "jj", ["--no-pager", "log", "--no-graph", "--color", "never", "-r", "@", "-T", tmpl], { cwd: root, encoding: "utf8", timeout: CONFIG.cmdTimeoutMs, windowsHide: true } ).trim(); const [change = "?", bookmarks = "", state = ""] = out.split("|"); const label = bookmarks.trim() || change; const dirty = state.trim() === "dirty"; const col = dirty ? c.yellow : c.green; return col(`jj:${label}`) + (dirty ? c.yellow("✗") : ""); } // ════════════════════════════════════════════════════════════════════════════ // caveman badge — terse: full→"cm", else "cm:" // ════════════════════════════════════════════════════════════════════════════ function cavemanBadge() { try { const raw = readSmallFile(path.join(claudeDir, ".caveman-active"), 64); if (raw == null) return ""; let mode = (raw.split(/\r?\n/)[0] ?? "") .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, ""); const valid = new Set([ "off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress", ]); if (!valid.has(mode) || mode === "off") return ""; let out = c.dim("caveman:") + c.accent(mode || "full"); // muted label, bright value if (process.env.CAVEMAN_STATUSLINE_SAVINGS !== "0") { const sraw = readSmallFile(path.join(claudeDir, ".caveman-statusline-suffix"), 64); const sv = sraw && stripCtl(sraw).trimEnd(); if (sv) out += " " + c.accent(sv); } return out; } catch { return ""; } }