#!/usr/bin/env node /** * @file Claude Code statusline for AgentHub. Reads the statusline JSON Claude * Code pipes on stdin and prints ONE line: model · git branch · context-window * bar · this session's $ (Claude Code's own `cost.total_cost_usd`) · today's $ * across every session (the dashboard's `GET /api/pricing/cost?tz_offset=`, * 1-second timeout, cached for 30 s in the OS temp dir, left out whenever the * dashboard is down). It never throws, never waits past ~2.5 s and always * exits 0 — a statusline is the last place to surface an error. Installed by * `agenthub statusline install`; see docs/STATUSLINE.md. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const CACHE_TTL_MS = 30_000; const COST_TIMEOUT_MS = 1_000; const color = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`; const CYAN = color("36"); const GREEN = color("32"); const YELLOW = color("33"); const RED = color("31"); const MAGENTA = color("35"); const DIM = color("2"); /** Percent of the context window in use, or null when the payload has none. */ function contextPercent(ctx) { if (!ctx || typeof ctx !== "object") return null; if (Number.isFinite(ctx.used_percentage)) return ctx.used_percentage; const u = ctx.current_usage; if (!u || !(ctx.context_window_size > 0)) return null; const used = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0); return (used / ctx.context_window_size) * 100; } const money = (n) => `$${n.toFixed(2)}`; /** The status line for one payload. `branch` and `today` are looked up by the caller. */ function format(input, { branch, today } = {}) { const parts = []; const model = input?.model?.display_name || input?.model?.id; if (model) parts.push(CYAN(model)); if (branch) parts.push(MAGENTA(branch)); const pct = contextPercent(input?.context_window); if (pct !== null) { const p = Math.max(0, Math.min(100, Math.round(pct))); const filled = Math.round(p / 10); const paint = p >= 80 ? RED : p >= 50 ? YELLOW : GREEN; parts.push(paint(`${"█".repeat(filled)}${"░".repeat(10 - filled)} ${p}%`)); } const session = Number(input?.cost?.total_cost_usd); if (input?.cost?.total_cost_usd != null && Number.isFinite(session)) parts.push(money(session)); if (Number.isFinite(today)) parts.push(DIM(`today ${money(today)}`)); return parts.join(DIM(" · ")); } /** Current branch (or short sha when detached) of `cwd`, or "" — 500 ms cap. */ function gitBranch(cwd) { if (!cwd) return ""; const git = (args) => execFileSync("git", ["--no-optional-locks", ...args], { cwd, timeout: 500, stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }) .toString() .trim(); try { return git(["symbolic-ref", "--short", "HEAD"]); } catch { try { return git(["rev-parse", "--short", "HEAD"]); } catch { return ""; } } } /** YYYY-MM-DD in local time — the key daily_costs uses for this tz_offset. */ function localDate(d = new Date()) { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } function cachePath() { const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username; return path.join(os.tmpdir(), `agenthub-statusline-${uid}.json`); } /** * Today's cost across all sessions from the dashboard, or null. A failed fetch * is cached too, so a stopped dashboard costs one timeout per TTL, not per line. */ async function todayCost({ url, file = cachePath(), now = Date.now() } = {}) { const date = localDate(new Date(now)); try { const c = JSON.parse(fs.readFileSync(file, "utf8")); if (c.url === url && c.date === date && now - c.at < CACHE_TTL_MS) return c.today; } catch { /* no cache yet */ } let today = null; try { const headers = process.env.DASHBOARD_TOKEN ? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` } : {}; // Server mode: inside a dashboard pane, act as the workspace's owner. if (process.env.AGENTHUB_PANE_TOKEN) headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN; const offset = new Date(now).getTimezoneOffset(); const res = await fetch(`${url}/api/pricing/cost?tz_offset=${offset}`, { headers, signal: AbortSignal.timeout(COST_TIMEOUT_MS), }); if (res.ok) { const data = await res.json(); const row = (data.daily_costs || []).find((d) => d.date === date); today = Number(row?.cost ?? 0); } } catch { /* dashboard down or slow — omit */ } try { fs.writeFileSync(file, JSON.stringify({ url, date, at: now, today }), { mode: 0o600 }); } catch { /* read-only tmp — fine */ } return today; } function dashboardUrl() { if (process.env.AGENTHUB_URL) return process.env.AGENTHUB_URL.replace(/\/+$/, ""); return require(path.join(__dirname, "..", "server", "lib", "dashboard-url.js")).dashboardUrl(); } /** Read stdin to EOF, or give up after `ms`. */ function readStdin(ms) { return new Promise((resolve) => { let buf = ""; const done = () => resolve(buf); setTimeout(done, ms).unref(); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", done); process.stdin.on("error", done); }); } async function main() { // Hard cap: whatever happens, Claude Code gets its prompt back. setTimeout(() => process.exit(0), 2_500).unref(); let line = ""; try { const input = JSON.parse((await readStdin(1_000)) || "{}"); const cwd = input?.workspace?.current_dir || input?.cwd; let today = null; try { today = await todayCost({ url: dashboardUrl() }); } catch { /* omit */ } line = format(input, { branch: gitBranch(cwd), today }); } catch { /* bad payload — print nothing */ } process.stdout.write(line + "\n", () => process.exit(0)); } if (require.main === module) main(); module.exports = { main, format, contextPercent, gitBranch, todayCost, localDate };