#!/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 "";
}
}