feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file `agenthub statusline install|uninstall`: writes or removes the
|
||||
* `statusLine` entry in Claude Code's user settings (`<CLAUDE_HOME>/settings.json`)
|
||||
* — only when the user runs the command, never at bootstrap. A statusLine that
|
||||
* is not AgentHub's is left alone unless `force` is set, and the file is backed
|
||||
* up beside itself (`settings.json.agenthub-backup-<stamp>`) before any write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..");
|
||||
|
||||
/** Our own entries: `node ".../statusline/statusline.js"` or `".../agenthub" statusline`. */
|
||||
const OURS = /(statusline[\\/]statusline\.js"?|agenthub"? statusline)\s*$/;
|
||||
const isOurs = (entry) => typeof entry?.command === "string" && OURS.test(entry.command);
|
||||
|
||||
/**
|
||||
* The command to register. A plugin install runs from a cache directory that
|
||||
* every update replaces, so it goes through the stable `~/.local/bin/agenthub`
|
||||
* launcher the bootstrap keeps pointed at the current build; a checkout runs
|
||||
* the script in place.
|
||||
*/
|
||||
function statuslineCommand(root = REPO_ROOT, home = os.homedir()) {
|
||||
const launcher = path.join(home, ".local", "bin", "agenthub");
|
||||
const inCache = root.includes(`${path.sep}plugins${path.sep}cache${path.sep}`);
|
||||
if (inCache && fs.existsSync(launcher)) return `"${launcher}" statusline`;
|
||||
return `node "${path.join(root, "statusline", "statusline.js")}"`;
|
||||
}
|
||||
|
||||
function readSettings(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return {};
|
||||
throw new Error(`cannot read ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(file, settings) {
|
||||
if (fs.existsSync(file)) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
fs.copyFileSync(file, `${file}.agenthub-backup-${stamp}`);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
/** @returns {{status: "installed"|"unchanged"|"foreign", command: string, previous?: object}} */
|
||||
function install(file, { force = false, command = statuslineCommand() } = {}) {
|
||||
const settings = readSettings(file);
|
||||
const current = settings.statusLine;
|
||||
if (current && !isOurs(current) && !force)
|
||||
return { status: "foreign", command, previous: current };
|
||||
if (current?.type === "command" && current.command === command)
|
||||
return { status: "unchanged", command };
|
||||
settings.statusLine = { type: "command", command, padding: 0 };
|
||||
writeSettings(file, settings);
|
||||
return { status: "installed", command, previous: current };
|
||||
}
|
||||
|
||||
/** @returns {{status: "removed"|"absent"|"foreign", previous?: object}} */
|
||||
function uninstall(file, { force = false } = {}) {
|
||||
const settings = readSettings(file);
|
||||
const current = settings.statusLine;
|
||||
if (!current) return { status: "absent" };
|
||||
if (!isOurs(current) && !force) return { status: "foreign", previous: current };
|
||||
delete settings.statusLine;
|
||||
writeSettings(file, settings);
|
||||
return { status: "removed", previous: current };
|
||||
}
|
||||
|
||||
module.exports = { install, uninstall, isOurs, statuslineCommand };
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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"],
|
||||
})
|
||||
.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}` }
|
||||
: {};
|
||||
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 };
|
||||
Reference in New Issue
Block a user