Files
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +07:00

177 lines
6.1 KiB
JavaScript

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