feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
+11
-2
@@ -5,7 +5,8 @@
|
||||
* transcript, its events, its cost — is written to `audit_log` with who read
|
||||
* it, whose it was and which path. `auditGuard` runs on `/api` after the
|
||||
* scope guard; it never blocks a request, and it is a no-op in local mode and
|
||||
* for a member (who can only read their own).
|
||||
* for a member (who can only read their own). An admin's account change on
|
||||
* the Users page is written here too (`recordAccountChange`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -57,4 +58,12 @@ function listAudit(limit = 100) {
|
||||
).all(n);
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit };
|
||||
/** An admin's account change from the Users page: who, on whom, what (never
|
||||
* a password). `owner_id` is the account changed; `path` carries the action. */
|
||||
function recordAccountChange(actorId, targetId, method, action) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, targetId, method, String(action).slice(0, 500));
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange };
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* its own bearer token), and everything else only with a valid login. An
|
||||
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
|
||||
* account's own configuration — are an `admin`'s.
|
||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
@@ -54,6 +57,7 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/remote-sources",
|
||||
"/reports",
|
||||
"/pets",
|
||||
"/users",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -291,6 +295,21 @@ async function login(req, username, password) {
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up check for an account change made in the dashboard: the acting
|
||||
* user's OWN password again, so a login left open (or a page that rode it)
|
||||
* cannot create an admin. Throttled like a login; throws EREAUTH/ETOOMANY.
|
||||
*/
|
||||
async function confirmPassword(user, password) {
|
||||
const key = `reauth:${user.id}`;
|
||||
if (blocked(key, FAIL_LIMIT)) throw err("ETOOMANY", "too many wrong passwords, try again later");
|
||||
noteAttempt(key);
|
||||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(user.id);
|
||||
const ok = await verifyPassword(String(password || ""), row?.password_hash || DUMMY_HASH);
|
||||
if (!row || !ok) throw err("EREAUTH", "your password is wrong");
|
||||
failures.delete(key);
|
||||
}
|
||||
|
||||
// ── Pane credentials ────────────────────────────────────────────────────────
|
||||
|
||||
const PANE_HEADER = "x-agenthub-pane-token";
|
||||
@@ -446,6 +465,7 @@ module.exports = {
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
confirmPassword,
|
||||
endSession,
|
||||
cookieToken,
|
||||
setLoginCookie,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file autostart.js
|
||||
* @description Settings → About's "Start automatically" switch: whether the
|
||||
* @description Settings → System's "Start automatically" switch: whether the
|
||||
* dashboard starts at boot/login through the per-user service
|
||||
* (`scripts/service-manager.js` — a systemd `--user` unit, or a LaunchAgent on
|
||||
* macOS). The switch IS the unit's enabled state, nothing stored beside it.
|
||||
|
||||
@@ -152,6 +152,11 @@ const USAGE_COLS = [
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
"long_input_tokens",
|
||||
"long_output_tokens",
|
||||
"long_cache_read_tokens",
|
||||
"long_cache_write_tokens",
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file commit-message.js
|
||||
* @description Suggests a commit message for a lane's STAGED changes with the
|
||||
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
|
||||
* @description Suggests a commit message for a lane's STAGED changes — or,
|
||||
* given `paths` (the Changes panel's ticked files), for exactly those files'
|
||||
* working-tree changes, untracked ones included — with the
|
||||
* user's own `claude` CLI: `claude -p` on that diff, sent on stdin, with
|
||||
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
|
||||
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
|
||||
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
|
||||
@@ -14,7 +16,7 @@ const { spawn } = require("node:child_process");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { isSecretOnDisk } = require("./git-diff");
|
||||
const { isSecretOnDisk, laneStatus } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
@@ -24,7 +26,7 @@ const MAX_DIFF_CHARS = 60_000;
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
const PROMPT =
|
||||
"Write a git commit message for the staged diff given on stdin. " +
|
||||
"Write a git commit message for the diff given on stdin. " +
|
||||
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
|
||||
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
|
||||
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
|
||||
@@ -33,28 +35,52 @@ function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** The text the model reads: recent subjects, the staged file list, the diff. */
|
||||
async function buildInput(dir) {
|
||||
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
/** A diff's text. An oversized one keeps git's partial output — still the
|
||||
* useful head — and so does `--no-index`, which exits 1 when files differ. */
|
||||
async function readDiff(dir, args) {
|
||||
try {
|
||||
return (await git(dir, args, { maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 })).stdout;
|
||||
} catch (err) {
|
||||
if (!err.git?.stdout) throw err;
|
||||
return String(err.git.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the model reads: recent subjects, the file list, the diff — of
|
||||
* what is staged, or of `picked`: paths `git status` reports changed right
|
||||
* now (EBADPATH otherwise), diffed against HEAD, an untracked one as new.
|
||||
*/
|
||||
async function buildInput(dir, picked) {
|
||||
const status = Array.isArray(picked)
|
||||
? new Map((await laneStatus(dir)).map((e) => [e.path, e.code]))
|
||||
: null;
|
||||
if (status) {
|
||||
const unknown = picked.find((p) => typeof p !== "string" || !status.has(p));
|
||||
if (unknown !== undefined) throw bad("EBADPATH", `not a changed path: ${unknown}`);
|
||||
}
|
||||
const names = status
|
||||
? picked
|
||||
: (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const paths = [];
|
||||
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
|
||||
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
|
||||
|
||||
const diffArgs = ["--no-color", "--no-ext-diff", "--relative"];
|
||||
let diff;
|
||||
try {
|
||||
diff = (
|
||||
await git(
|
||||
dir,
|
||||
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
|
||||
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
|
||||
)
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// An oversized diff: git's partial output is still the useful head.
|
||||
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
|
||||
diff = String(err.git.stdout);
|
||||
if (!status) {
|
||||
diff = await readDiff(dir, ["diff", "--cached", ...diffArgs, "--", ...paths]);
|
||||
} else {
|
||||
const tracked = paths.filter((p) => status.get(p) !== "??");
|
||||
diff = tracked.length
|
||||
? await readDiff(dir, ["diff", "HEAD", ...diffArgs, "--", ...tracked]).catch(() => "")
|
||||
: "";
|
||||
for (const p of paths.filter((p) => status.get(p) === "??")) {
|
||||
if (diff.length > MAX_DIFF_CHARS) break;
|
||||
diff += await readDiff(dir, ["diff", "--no-index", "--no-color", "--", "/dev/null", p]);
|
||||
}
|
||||
}
|
||||
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
|
||||
|
||||
@@ -64,8 +90,8 @@ async function buildInput(dir) {
|
||||
|
||||
return [
|
||||
log ? `Recent commit subjects:\n${log}` : "",
|
||||
`Staged files:\n${paths.join("\n")}`,
|
||||
`Staged diff:\n${diff}`,
|
||||
`${status ? "Files" : "Staged files"}:\n${paths.join("\n")}`,
|
||||
`${status ? "Diff" : "Staged diff"}:\n${diff}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -96,13 +122,14 @@ function run(bin, args, input) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested message for what `dir` has staged.
|
||||
* A suggested message for what `dir` has staged, or for `paths`.
|
||||
* @param {string} dir the lane's (or session worktree's) cwd
|
||||
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
|
||||
* @param {{bin?: string, paths?: string[]}} [opts] `bin` overrides the
|
||||
* detected `claude` (tests); `paths` = the ticked files
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function suggestCommitMessage(dir, { bin } = {}) {
|
||||
const input = await buildInput(dir);
|
||||
async function suggestCommitMessage(dir, { bin, paths } = {}) {
|
||||
const input = await buildInput(dir, paths);
|
||||
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
|
||||
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
|
||||
const out = await run(
|
||||
|
||||
+89
-13
@@ -657,7 +657,9 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
|
||||
}
|
||||
// Through the same lexical guard as every other path: in a subdirectory
|
||||
// lane `../x` would list commit metadata for a path outside the lane.
|
||||
if (file) args.push("--", lanePath(file));
|
||||
// `--parents` makes git REWRITE each parent to the file's own previous
|
||||
// commit, so a file's history draws one joined-up line, not loose ends.
|
||||
if (file) args.push("--parents", "--", lanePath(file));
|
||||
const result = await git(dir, args);
|
||||
const commits = [];
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
@@ -682,6 +684,50 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate (JetBrains' blame gutter): who last changed each line of the
|
||||
* working-tree file. `git blame --porcelain` on the file on disk, so a line
|
||||
* nobody committed yet is `null`. Same guards as a whole-file read: the
|
||||
* lexical lane guard, a secret refused (ESECRET), and the file's size capped.
|
||||
* Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`.
|
||||
*/
|
||||
async function blameFile(dir, requested) {
|
||||
const file = lanePath(requested);
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
}
|
||||
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
|
||||
if (!st?.isFile()) throw badRequest("ENOFILE", "no such file");
|
||||
if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate");
|
||||
const out = (
|
||||
await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 })
|
||||
).stdout;
|
||||
const commits = {};
|
||||
const lines = [];
|
||||
let sha = null;
|
||||
for (const line of out.split("\n")) {
|
||||
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
|
||||
if (head) {
|
||||
sha = head[1];
|
||||
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("\t")) {
|
||||
lines.push(/^0+$/.test(sha) ? null : sha);
|
||||
continue;
|
||||
}
|
||||
const c = commits[sha];
|
||||
if (!c) continue;
|
||||
const sp = line.indexOf(" ");
|
||||
const key = line.slice(0, sp);
|
||||
const val = line.slice(sp + 1);
|
||||
if (key === "author") c.author = val;
|
||||
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
|
||||
else if (key === "summary") c.summary = val;
|
||||
}
|
||||
return { path: file, commits, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's full content at a revision, for the "read the whole file" view
|
||||
* that a diff's three lines of context cannot answer.
|
||||
@@ -981,7 +1027,18 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
|
||||
*/
|
||||
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
|
||||
if (!header || (await isSecretOnDisk(dir, file))) return null;
|
||||
if (!header) return null;
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (!hunk) return null;
|
||||
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
|
||||
return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null;
|
||||
}
|
||||
|
||||
/** A tracked file's fresh `git diff HEAD` at `context`: its header lines and
|
||||
* its hunks' texts, in order. Null for a secret, binary or too-large file. */
|
||||
async function diffHunks(dir, file, context) {
|
||||
if (await isSecretOnDisk(dir, file)) return null;
|
||||
const u = `-U${normalizeContext(context)}`;
|
||||
const raw = await readCapped(
|
||||
dir,
|
||||
@@ -992,16 +1049,31 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
|
||||
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
|
||||
const at = raw.indexOf("\n@@ ");
|
||||
if (at < 0) return null;
|
||||
const head = raw.slice(0, at + 1);
|
||||
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
|
||||
// line that does is a hunk boundary.
|
||||
const hunk = raw
|
||||
.slice(at + 1)
|
||||
.split(/^(?=@@ )/m)
|
||||
.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (!hunk) return null;
|
||||
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
|
||||
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
|
||||
return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunk-level commit's patch for one file (JetBrains' partial commit):
|
||||
* the file's header plus each picked hunk cut down to its picked `blocks`,
|
||||
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
||||
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
||||
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
||||
* git wrote it — `git apply` locates a hunk by its old side.
|
||||
*/
|
||||
async function selectionPatch(dir, file, context, picks) {
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
if (!diff || !picks.length) return null;
|
||||
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
||||
const bodies = [];
|
||||
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
|
||||
if (!body) return null;
|
||||
bodies.push(body);
|
||||
}
|
||||
return diff.head + bodies.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1020,7 +1092,9 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
|
||||
* checked side is unchanged. Null when the hunk has no such block.
|
||||
*/
|
||||
function onlyBlock(hunk, block, reverse) {
|
||||
if (!Number.isInteger(block) || block < 0) return null;
|
||||
// One block, or several (the hunk-level commit keeps every ticked one).
|
||||
const keep = Array.isArray(block) ? block : [block];
|
||||
if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null;
|
||||
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
|
||||
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||
if (!m) return null;
|
||||
@@ -1040,7 +1114,7 @@ function onlyBlock(hunk, block, reverse) {
|
||||
if (change && !inBlock) index += 1;
|
||||
inBlock = change;
|
||||
let kept = line;
|
||||
if (change && index !== block) {
|
||||
if (change && !keep.includes(index)) {
|
||||
const fold = reverse ? "+" : "-";
|
||||
kept = mark === fold ? ` ${line.slice(1)}` : null;
|
||||
}
|
||||
@@ -1050,7 +1124,7 @@ function onlyBlock(hunk, block, reverse) {
|
||||
if (kept[0] !== "+") oldCount += 1;
|
||||
if (kept[0] !== "-") newCount += 1;
|
||||
}
|
||||
if (block > index) return null;
|
||||
if (Math.max(...keep) > index) return null;
|
||||
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
|
||||
}
|
||||
|
||||
@@ -1103,6 +1177,8 @@ module.exports = {
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
hunkPatch,
|
||||
selectionPatch,
|
||||
blameFile,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
|
||||
+179
-11
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, create or
|
||||
* stage/unstage a file, commit (the index, everything, or exactly the ticked
|
||||
* `paths`), discard a file, add an untracked file to the workspace's
|
||||
* .gitignore (`ignore`), switch branch, create or
|
||||
* delete a branch, publish a repo that has no remote yet, and merge a branch
|
||||
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
|
||||
@@ -59,7 +61,7 @@ const {
|
||||
operationInProgress,
|
||||
branchRef,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
@@ -91,6 +93,7 @@ const ACTIONS = new Set([
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"ignore",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
|
||||
@@ -476,6 +479,11 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// would switch it to committing the working-tree copy (unstaged edits
|
||||
// included) — so a subdirectory lane refuses rather than sweep another
|
||||
// actor's staged work outside it into this lane's commit.
|
||||
// `paths` (the panel's ticked files): exactly those, whatever the index
|
||||
// holds — `--only` leaves every other change, staged or not, outside.
|
||||
if (hasHunks(body)) return done(action, await commitSelection(dir, ["-m", message], body));
|
||||
if (Array.isArray(body.paths))
|
||||
return done(action, await commitPaths(dir, ["-m", message], body));
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
@@ -497,6 +505,8 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["commit", "-m", message]));
|
||||
}
|
||||
|
||||
if (action === "ignore") return ignorePaths(dir, body);
|
||||
|
||||
if (action === "branch-create" || action === "branch-delete") {
|
||||
return branchAction(dir, action, body);
|
||||
}
|
||||
@@ -627,7 +637,9 @@ async function commitSha(dir, sha) {
|
||||
|
||||
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
|
||||
async function parentCount(dir, sha) {
|
||||
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
|
||||
return (
|
||||
(await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,7 +658,13 @@ async function assertUnpushedHead(dir, sha) {
|
||||
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
|
||||
}
|
||||
const on = (
|
||||
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--contains",
|
||||
"HEAD",
|
||||
"--format=%(refname:short)",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout.trim();
|
||||
if (on) {
|
||||
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
|
||||
@@ -670,7 +688,8 @@ async function historyAction(dir, action, body) {
|
||||
const open = await operationInProgress(dir);
|
||||
const conflicts = open ? await unmergedPaths(dir) : [];
|
||||
// Conflicts: an operation that STARTED, finished from the banner.
|
||||
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
|
||||
if (conflicts.length)
|
||||
return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
|
||||
// Nothing to resolve, so nothing may be left behind either: a pick
|
||||
// whose changes are already here (EEMPTY), or a commit git could not
|
||||
// make (no identity, a failing signer) — which can leave the changes
|
||||
@@ -687,8 +706,14 @@ async function historyAction(dir, action, body) {
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
if (touched.length) {
|
||||
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
|
||||
.catch(() => {});
|
||||
await git(dir, [
|
||||
"restore",
|
||||
"--source=HEAD",
|
||||
"--staged",
|
||||
"--worktree",
|
||||
"--",
|
||||
...touched,
|
||||
]).catch(() => {});
|
||||
}
|
||||
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
|
||||
throw e;
|
||||
@@ -709,19 +734,162 @@ async function historyAction(dir, action, body) {
|
||||
// amend: the index as it stands folds into the last commit, like `commit`
|
||||
// with `only: "staged"`; no message keeps the old one.
|
||||
await assertUnpushedHead(dir, body.sha);
|
||||
await assertNothingStagedOutside(dir);
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
const msgArgs = message ? ["-m", message] : ["--no-edit"];
|
||||
if (hasHunks(body)) {
|
||||
return done(action, await commitSelection(dir, ["--amend", ...msgArgs], body));
|
||||
}
|
||||
if (Array.isArray(body.paths)) {
|
||||
// No ticked file: `--only` with no path rewords and leaves the index out.
|
||||
if (!body.paths.length) {
|
||||
if (!message) throw bad("ENOCHANGES", "no file ticked and no new message to amend with");
|
||||
return done(action, await git(dir, ["commit", "--amend", "--only", ...msgArgs]));
|
||||
}
|
||||
return done(action, await commitPaths(dir, ["--amend", ...msgArgs], body));
|
||||
}
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (!message) {
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
|
||||
return done(action, await git(dir, ["commit", "--amend", ...msgArgs]));
|
||||
}
|
||||
|
||||
/**
|
||||
* `git commit --only -- <paths>`: the working-tree content of exactly the
|
||||
* paths the request ticked (JetBrains' commit), each one `git status` reports
|
||||
* changed. An untracked one is added first — `--only` takes only paths git
|
||||
* knows — and taken back out of the index if the commit then fails.
|
||||
*/
|
||||
async function commitPaths(dir, args, body) {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const conflicted = paths.find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
// A rename's original path rides along, or its deletion stays behind.
|
||||
const all = paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
if (untracked.length) await git(dir, ["add", "--", ...untracked]);
|
||||
try {
|
||||
return await git(dir, ["commit", ...args, "--only", "--", ...all]);
|
||||
} catch (e) {
|
||||
if (untracked.length)
|
||||
await git(dir, ["rm", "--cached", "-q", "--", ...untracked]).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the request names change blocks (`hunks`), not just whole files. */
|
||||
function hasHunks(body) {
|
||||
return !!body.hunks && typeof body.hunks === "object" && Object.keys(body.hunks).length > 0;
|
||||
}
|
||||
|
||||
/** Most files one hunk-level commit may name partially. */
|
||||
const MAX_PARTIAL_FILES = 300;
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
const picks = Object.entries(body.hunks);
|
||||
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
||||
for (const [, list] of picks) {
|
||||
const ok =
|
||||
Array.isArray(list) &&
|
||||
list.length > 0 &&
|
||||
list.every(
|
||||
(x) =>
|
||||
x &&
|
||||
typeof x.hunk === "string" &&
|
||||
Array.isArray(x.blocks) &&
|
||||
x.blocks.length > 0 &&
|
||||
x.blocks.every((b) => Number.isInteger(b) && b >= 0)
|
||||
);
|
||||
if (!ok) throw bad("EBADPATH", "hunks: each file needs [{hunk, blocks: [n, …]}]");
|
||||
}
|
||||
const whole = Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : [];
|
||||
const part = await resolvePaths(dir, { paths: picks.map(([p]) => p) });
|
||||
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
for (const p of part) {
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
|
||||
if (whole.some((w) => w.path === p.path)) {
|
||||
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
||||
}
|
||||
}
|
||||
const wholePaths = whole.flatMap((p) =>
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
|
||||
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
|
||||
try {
|
||||
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
|
||||
env,
|
||||
});
|
||||
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
|
||||
const file = path.join(tmp, "part.patch");
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
await fs.promises.writeFile(file, patch);
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
await git(top, ["apply", "--cached", ...zero, file], { env });
|
||||
}
|
||||
const out = await git(dir, ["commit", ...args], { env });
|
||||
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
|
||||
() => {}
|
||||
);
|
||||
return out;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A path as one literal .gitignore line, anchored at the file's folder. */
|
||||
function ignoreLine(p) {
|
||||
return `/${p.replace(/[\\*?[]/g, "\\$&").replace(/ $/, "\\ ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to .gitignore: one anchored line per path in the WORKSPACE's own
|
||||
* .gitignore (its cwd — a subdirectory workspace never writes above itself),
|
||||
* only for a path `git status` lists as untracked, never a line it already
|
||||
* has, and never through a symlink or onto anything but a regular file.
|
||||
*/
|
||||
async function ignorePaths(dir, body) {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const tracked = paths.find((p) => p.code !== "??");
|
||||
if (tracked)
|
||||
throw bad("ETRACKED", `${tracked.path} is tracked: only an untracked file can be ignored`);
|
||||
// A line break in a name would write a second, arbitrary pattern.
|
||||
if (paths.some((p) => /[\r\n]/.test(p.path)))
|
||||
throw bad("EBADPATH", "a path with a line break cannot be ignored");
|
||||
const file = path.join(dir, ".gitignore");
|
||||
const st = await fs.promises.lstat(file).catch(() => null);
|
||||
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
|
||||
const old = st ? await fs.promises.readFile(file, "utf8") : "";
|
||||
const have = new Set(old.split(/\r?\n/));
|
||||
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
|
||||
if (lines.length) {
|
||||
const sep = old && !old.endsWith("\n") ? "\n" : "";
|
||||
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
|
||||
}
|
||||
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
const TranscriptCache = require("./transcript-cache");
|
||||
const { longArgs } = require("./token-usage");
|
||||
|
||||
/** Write every hour of one transcript parse (MAX per hour, see the statement). */
|
||||
function writeHourly(sessionId, tokensByHour) {
|
||||
@@ -31,6 +32,9 @@ function writeHourly(sessionId, tokensByHour) {
|
||||
t.webFetch,
|
||||
t.codeExec
|
||||
);
|
||||
const long = longArgs(t);
|
||||
if (long)
|
||||
stmts.raiseLongHourly.run(...long, sessionId, t.hour, t.model, t.speed, t.geo, t.tier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -3,7 +3,8 @@
|
||||
* @description Custom pets for the dashboard mascot (Sagi). A pet is a folder
|
||||
* `<data dir>/pets/<id>/` holding `meta.json` (written here) and either a
|
||||
* Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of
|
||||
* 192×208 frames, one row per animation state — or one uploaded image. Pets
|
||||
* 192×208 frames, one row per animation state (v2 adds two rows of gaze
|
||||
* poses: 8×11) — or one uploaded image. Pets
|
||||
* come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded
|
||||
* image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed
|
||||
* id — never a URL or a path from a request. None is bundled with AgentHub:
|
||||
@@ -28,6 +29,8 @@ const MAX_MANIFEST_BYTES = 16 * 1024;
|
||||
const MAX_PETS = 50;
|
||||
/** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */
|
||||
const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 };
|
||||
/** A v2 atlas (`spriteVersionNumber: 2`): the same plus rows 9–10, 16 gaze poses. */
|
||||
const SHEET_V2_HEIGHT = 2288;
|
||||
const FEATURED_HOST = "https://codex-pets.net";
|
||||
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
|
||||
const FEATURED = [
|
||||
@@ -105,10 +108,13 @@ function checkSheet(buf) {
|
||||
if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
|
||||
const size = webpSize(buf);
|
||||
if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image");
|
||||
if (size.width !== SHEET.width || size.height !== SHEET.height)
|
||||
if (
|
||||
size.width !== SHEET.width ||
|
||||
(size.height !== SHEET.height && size.height !== SHEET_V2_HEIGHT)
|
||||
)
|
||||
throw fail(
|
||||
"EBADPET",
|
||||
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height}`
|
||||
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height} (v2: ${SHEET.width}×${SHEET_V2_HEIGHT})`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
|
||||
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
|
||||
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
|
||||
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
|
||||
// A request whose whole prompt (input + cache read + cache write) is above this
|
||||
// is booked as context_tier "long" and priced at the rule's long_* rates.
|
||||
// ponytail: one threshold for every model (Haiku 5.5's 100k); a model with a
|
||||
// different one needs the threshold stored per bucket, then a re-parse.
|
||||
const LONG_CONTEXT_TOKENS = 100_000;
|
||||
|
||||
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
|
||||
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
|
||||
@@ -48,6 +53,7 @@ module.exports = {
|
||||
CACHE_WRITE_1H_MULTIPLIER,
|
||||
DATA_RESIDENCY_US_MULTIPLIER,
|
||||
BATCH_DISCOUNT_MULTIPLIER,
|
||||
LONG_CONTEXT_TOKENS,
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
WEB_FETCH_PER_REQUEST,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
* fast columns: Codex's fast/priority service tier is booked as `speed = fast`
|
||||
* (`codex-usage.js`), and its flex tier as `service_tier = 'flex'` (half price,
|
||||
* like batch, in `ratesForBucket`).
|
||||
* A Claude model's `*_above_100k_tokens` input/output rates (Haiku 5.5) fill
|
||||
* the long_* columns, priced against token_usage's long-context subsets.
|
||||
* ponytail: OpenAI's long-context (>272k) and Gemini's >200k-token rates are
|
||||
* not modelled — Codex's context window (258k) stays under 272k and no current
|
||||
* Claude model has such a tier; it needs a context-size bucket in token_usage
|
||||
* (a primary-key rebuild) if that changes.
|
||||
* not modelled — Codex's context window (258k) stays under 272k, and a
|
||||
* threshold other than LONG_CONTEXT_TOKENS (100k) needs its own subsets.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -112,6 +113,14 @@ function toRow(fam, e) {
|
||||
: [per(e.input_cost_per_token_priority), per(e.output_cost_per_token_priority)];
|
||||
const prioOk =
|
||||
prio.length > 0 && prio.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
|
||||
const long = claude
|
||||
? [
|
||||
per(e.input_cost_per_token_above_100k_tokens),
|
||||
per(e.output_cost_per_token_above_100k_tokens),
|
||||
]
|
||||
: [];
|
||||
const longOk =
|
||||
long.length > 0 && long.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
|
||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||
return {
|
||||
@@ -124,6 +133,8 @@ function toRow(fam, e) {
|
||||
w1h: r(w1h),
|
||||
fastIn: fastOk ? r(input * fast) : prioOk ? r(prio[0]) : 0,
|
||||
fastOut: fastOk ? r(output * fast) : prioOk ? r(prio[1]) : 0,
|
||||
longIn: longOk ? r(long[0]) : 0,
|
||||
longOut: longOk ? r(long[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +203,8 @@ const RATE_COLS = [
|
||||
["w1h", "cache_write_1h_per_mtok"],
|
||||
["fastIn", "fast_input_per_mtok"],
|
||||
["fastOut", "fast_output_per_mtok"],
|
||||
["longIn", "long_input_per_mtok"],
|
||||
["longOut", "long_output_per_mtok"],
|
||||
];
|
||||
const sameRates = (row, db) => RATE_COLS.every(([k, col]) => Math.abs(row[k] - db[col]) < 1e-9);
|
||||
|
||||
@@ -253,12 +266,14 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||
fast_input_per_mtok, fast_output_per_mtok, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
|
||||
fast_input_per_mtok, fast_output_per_mtok, long_input_per_mtok, long_output_per_mtok, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
|
||||
);
|
||||
const update = db.prepare(
|
||||
`UPDATE model_pricing SET display_name = ?, input_per_mtok = ?, output_per_mtok = ?,
|
||||
cache_read_per_mtok = ?, cache_write_per_mtok = ?, cache_write_1h_per_mtok = ?,
|
||||
fast_input_per_mtok = ?, fast_output_per_mtok = ?,
|
||||
long_input_per_mtok = ?, long_output_per_mtok = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE model_pattern = ? AND source = 'sync'`
|
||||
);
|
||||
@@ -273,7 +288,17 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
||||
if (dismissed.has(pattern)) continue;
|
||||
const row = toRow(fam, list[fam]);
|
||||
if (!row) continue;
|
||||
const rates = [row.input, row.output, row.read, row.w5m, row.w1h, row.fastIn, row.fastOut];
|
||||
const rates = [
|
||||
row.input,
|
||||
row.output,
|
||||
row.read,
|
||||
row.w5m,
|
||||
row.w1h,
|
||||
row.fastIn,
|
||||
row.fastOut,
|
||||
row.longIn,
|
||||
row.longOut,
|
||||
];
|
||||
const have = rows.get(pattern);
|
||||
if (!have) {
|
||||
if (insert.run(pattern, row.name, ...rates).changes) added.push(pattern);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file quit.js — Quit AgentHub (issue #65, 9router's tray "Quit"): stop the
|
||||
* dashboard and every lane pane, and keep it stopped until the user starts it
|
||||
* again. "Stopped" is the file `<data dir>/runtime/stopped`: the plugin
|
||||
* bootstrap starts no server while it exists, and every server that starts
|
||||
* clears it — so a boot or login through an enabled autostart unit still
|
||||
* brings the dashboard back, and only Claude Code's SessionStart is held off.
|
||||
* Kept free of anything but fs/path: the bootstrap reads it before the
|
||||
* runtime's dependencies are installed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
function flagPath(rt = path.join(getDataDir(), "runtime")) {
|
||||
return path.join(rt, "stopped");
|
||||
}
|
||||
|
||||
function isQuit(rt) {
|
||||
return fs.existsSync(flagPath(rt));
|
||||
}
|
||||
|
||||
function markQuit(rt) {
|
||||
const file = flagPath(rt);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${new Date().toISOString()}\n`, "utf8");
|
||||
}
|
||||
|
||||
function clearQuit(rt) {
|
||||
fs.rmSync(flagPath(rt), { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops this server. A unit of ours that is running is stopped THROUGH its
|
||||
* service manager: a SIGTERM'd server exits 0, which `Restart=always` /
|
||||
* `KeepAlive` answer by starting it again. Async spawns, because launchd's
|
||||
* `bootout` waits for the job — this process — to exit. If this process was
|
||||
* not the unit's (a second dashboard beside it), it follows a moment later.
|
||||
*/
|
||||
function stopServer({
|
||||
svc = require("../../scripts/service-manager"),
|
||||
spawn = require("child_process").spawn,
|
||||
kill = () => process.kill(process.pid, "SIGTERM"),
|
||||
} = {}) {
|
||||
const owned =
|
||||
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
|
||||
if (!owned) return kill();
|
||||
const argv =
|
||||
svc.kind === "launchd"
|
||||
? ["launchctl", ["bootout", `gui/${process.getuid()}/io.smartgift.agenthub`]]
|
||||
: ["systemctl", ["--user", "stop", "--no-block", "agenthub.service"]];
|
||||
spawn(argv[0], argv[1], { detached: true, stdio: "ignore" }).unref();
|
||||
setTimeout(kill, 5000).unref();
|
||||
}
|
||||
|
||||
module.exports = { flagPath, isQuit, markQuit, clearQuit, stopServer };
|
||||
+342
-12
@@ -18,38 +18,89 @@
|
||||
* never that account's usage.
|
||||
* - **Codex**: no network. Every `token_count` event Codex writes into its
|
||||
* rollout journal carries the account's `rate_limits`; the newest one wins.
|
||||
* - **Cursor**: the Cursor CLI's (`cursor-agent`) own login —
|
||||
* `$XDG_CONFIG_HOME/cursor/auth.json` — sent ONLY to cursor.com's
|
||||
* `GET /api/usage-summary` (undocumented; what cursor.com/dashboard reads),
|
||||
* as the `WorkosCursorSessionToken=<user id>::<token>` cookie it expects,
|
||||
* while the token is valid and never refreshed. One `mo` window: the
|
||||
* billing cycle's `totalPercentUsed`. No file (macOS keeps it in the
|
||||
* keychain) means no row.
|
||||
* - **Antigravity**: `agy --print /usage --output-format json` — the CLI's
|
||||
* own `/usage` command (no model call: `num_turns` 0, no conversation),
|
||||
* run by the catalog-detected binary in a private empty directory, only
|
||||
* when the tool is enabled and `agy --version` is at least 1.1.11 (an
|
||||
* older agy may bill `/usage` as a model request). One window per pool
|
||||
* bucket, `group` naming the pool (Gemini / Claude and GPT). Its report
|
||||
* names no account and no plan. Cached like Claude; a failed run shows
|
||||
* the last figure with its `asOf`. Running agy may let it update itself;
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
|
||||
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
|
||||
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
|
||||
* from `used / monthlyLimit`, plan `subscriptionTier`.
|
||||
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
|
||||
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
|
||||
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
|
||||
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
|
||||
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
|
||||
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
|
||||
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
|
||||
* key (another provider's login) means no row.
|
||||
* Grok's and Kimi's tokens are used only while valid and never refreshed
|
||||
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
|
||||
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email) and `expiresAt`,
|
||||
* when its stored access token runs out — Claude's `claudeAiOauth.expiresAt`,
|
||||
* Codex's `auth.json` access-token `exp` (claims read, never verified).
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||
* `expiresAt`, when its stored access token runs out — Claude's
|
||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
||||
* read, never verified).
|
||||
*
|
||||
* Only percentages, window lengths, reset/expiry times and the account's
|
||||
* email leave this module — never a token.
|
||||
* Only percentages, window lengths, reset/expiry times, the plan's name and
|
||||
* the account's email leave this module — never a token.
|
||||
* @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 { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
const { Database } = require("../db");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const CODEX_FILES = 5;
|
||||
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
|
||||
const AGY_TIMEOUT_MS = 60_000;
|
||||
const AGY_MIN_VERSION = [1, 1, 11];
|
||||
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
|
||||
const lastGood = new Map(); // profile name → { windows, at }
|
||||
|
||||
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click). */
|
||||
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click);
|
||||
* a call still in flight is shared, so two open tabs never run it twice. */
|
||||
async function cached(key, force, fn) {
|
||||
const hit = cache.get(key);
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
|
||||
const value = await fn();
|
||||
const value = fn();
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
@@ -94,6 +145,7 @@ async function liveClaude() {
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
plan: oauth.subscriptionType || null,
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
@@ -121,7 +173,11 @@ async function liveClaude() {
|
||||
* the last figure this process read is shown with its `asOf`.
|
||||
*/
|
||||
async function profileClaude(item) {
|
||||
const who = { account: item.email || item.displayName || null, expiresAt: iso(item.expiresAt) };
|
||||
const who = {
|
||||
account: item.email || item.displayName || null,
|
||||
plan: item.subscriptionType || null,
|
||||
expiresAt: iso(item.expiresAt),
|
||||
};
|
||||
let creds = null;
|
||||
try {
|
||||
creds = readProfile(item.name)?.credentials || null;
|
||||
@@ -225,13 +281,15 @@ function jwtClaims(token) {
|
||||
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
||||
}
|
||||
|
||||
/** Codex's ChatGPT login from `auth.json`: email + access-token expiry. */
|
||||
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
||||
function codexAccount() {
|
||||
try {
|
||||
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
|
||||
const { exp } = jwtClaims(tokens.access_token);
|
||||
const id = jwtClaims(tokens.id_token);
|
||||
return {
|
||||
account: jwtClaims(tokens.id_token).email || null,
|
||||
account: id.email || null,
|
||||
plan: id["https://api.openai.com/auth"]?.chatgpt_plan_type || null,
|
||||
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
|
||||
};
|
||||
} catch {
|
||||
@@ -260,8 +318,273 @@ function codexQuota() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Cursor CLI's login and the account's plan + billing-cycle usage. */
|
||||
async function cursorQuota() {
|
||||
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
const token = readJson(path.join(configHome, "cursor", "auth.json"))?.accessToken;
|
||||
if (typeof token !== "string" || !token) return null; // not logged in
|
||||
let claims = {};
|
||||
try {
|
||||
claims = jwtClaims(token);
|
||||
} catch {
|
||||
/* not a JWT: no expiry to check, cursor.com decides */
|
||||
}
|
||||
const info = readJson(path.join(getCursorDir(), "cli-config.json"))?.authInfo || {};
|
||||
const who = {
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: info.email || null,
|
||||
plan: null,
|
||||
expiresAt: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,
|
||||
};
|
||||
if (claims.exp && !fresh(claims.exp * 1000))
|
||||
return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
// The cookie names the user as the dashboard does: `user_…`, no `auth0|`.
|
||||
const user = String(info.authId || info.userId || claims.sub || "")
|
||||
.split("|")
|
||||
.pop();
|
||||
try {
|
||||
const res = await fetch(CURSOR_USAGE_URL, {
|
||||
headers: { Accept: "application/json", Cookie: `WorkosCursorSessionToken=${user}::${token}` },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ...who, windows: [], error: res.status === 401 ? "ETOKENEXPIRED" : "EUPSTREAM" };
|
||||
}
|
||||
const u = await res.json();
|
||||
const p = u?.individualUsage?.plan;
|
||||
// Enterprise seats answer without `plan`: the plan name, no figure.
|
||||
const used = typeof p?.totalPercentUsed === "number" ? p.totalPercentUsed : null;
|
||||
return {
|
||||
...who,
|
||||
plan: u?.membershipType || null,
|
||||
windows:
|
||||
used === null
|
||||
? []
|
||||
: [{ label: "mo", usedPercent: used, resetsAt: u.billingCycleEnd || null }],
|
||||
};
|
||||
} catch {
|
||||
return { ...who, windows: [], error: "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
|
||||
/** `agy --version` at least `AGY_MIN_VERSION`. */
|
||||
function agyVersionOk(text) {
|
||||
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
||||
if (!m) return false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = Number(m[i + 1]) - AGY_MIN_VERSION[i];
|
||||
if (d) return d > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `/usage`'s pools → windows: 5h before the week, in the pools' order. */
|
||||
function agyWindows(report) {
|
||||
const windows = [];
|
||||
for (const g of report?.command?.data?.groups || []) {
|
||||
const rows = (g.buckets || [])
|
||||
.filter((b) => AGY_WINDOW[b.window] && typeof b.remaining_fraction === "number")
|
||||
.map((b) => {
|
||||
const resets = Date.parse(b.reset_time);
|
||||
return {
|
||||
group: String(g.name || ""),
|
||||
label: AGY_WINDOW[b.window],
|
||||
// A window past its reset is full again, as Codex's is.
|
||||
usedPercent: resets < Date.now() ? 0 : (1 - b.remaining_fraction) * 100,
|
||||
resetsAt: Number.isFinite(resets) ? new Date(resets).toISOString() : null,
|
||||
};
|
||||
});
|
||||
rows.sort((a, b) => (a.label === "5h" ? -1 : 0) - (b.label === "5h" ? -1 : 0));
|
||||
windows.push(...rows);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
async function antigravityQuota() {
|
||||
if (process.env.AGENTHUB_AGY_USAGE === "0" || !aiTools.isEnabled("antigravity")) return null;
|
||||
const { installed, path: bin } = aiTools.detect("antigravity");
|
||||
if (!installed || !bin) return null;
|
||||
const run = (args, cwd) =>
|
||||
execFileAsync(bin, args, { cwd, timeout: AGY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
|
||||
try {
|
||||
if (!agyVersionOk((await run(["--version"], dir)).stdout)) return null;
|
||||
const { stdout } = await run(["--print", "/usage", "--output-format", "json"], dir);
|
||||
const windows = agyWindows(JSON.parse(stdout));
|
||||
if (!windows.length) throw new Error("no quota in /usage");
|
||||
lastGood.set("antigravity", { windows, at: Date.now() });
|
||||
return { id: "antigravity", active: true, windows };
|
||||
} catch {
|
||||
const prev = lastGood.get("antigravity");
|
||||
if (!prev) return { id: "antigravity", active: true, windows: [], error: "EUPSTREAM" };
|
||||
return {
|
||||
id: "antigravity",
|
||||
active: true,
|
||||
windows: prev.windows.map(expireReset),
|
||||
asOf: iso(prev.at),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const isoOf = (v) => {
|
||||
const ms = Date.parse(v);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
};
|
||||
|
||||
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
|
||||
async function bearerWindows(who, url, token, headers, toWindows) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const expired = res.status === 401 || res.status === 403;
|
||||
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
|
||||
}
|
||||
return { ...who, ...toWindows(await res.json()) };
|
||||
} catch {
|
||||
return { ...who, windows: [], error: "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Grok Build's `auth.x.ai` session and its billing view. */
|
||||
async function grokQuota() {
|
||||
const home = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
|
||||
const sessions = readJson(path.join(home, "auth.json"));
|
||||
if (!sessions || typeof sessions !== "object") return null;
|
||||
const xai = Object.entries(sessions)
|
||||
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
|
||||
.map(([, s]) => s);
|
||||
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
|
||||
const s = xai.find(live) || xai[0];
|
||||
if (!s) return null;
|
||||
const who = {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: s.email || null,
|
||||
plan: null,
|
||||
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
|
||||
};
|
||||
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
const headers = {
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
...(s.user_id && { "x-userid": String(s.user_id) }),
|
||||
};
|
||||
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
|
||||
const c = body?.config || body || {};
|
||||
const windows = [];
|
||||
// ponytail: a reply with no percent shows no week; Orca infers 0 from
|
||||
// the period fields — add that if a real account ever answers so.
|
||||
if (typeof c.creditUsagePercent === "number") {
|
||||
windows.push({
|
||||
label: "wk",
|
||||
usedPercent: c.creditUsagePercent,
|
||||
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
const limit = Number(c.monthlyLimit?.val);
|
||||
const used = Number(c.used?.val);
|
||||
if (limit > 0 && Number.isFinite(used)) {
|
||||
windows.push({
|
||||
label: "mo",
|
||||
usedPercent: (used / limit) * 100,
|
||||
resetsAt: isoOf(c.billingPeriodEnd),
|
||||
});
|
||||
}
|
||||
return { plan: c.subscriptionTier || null, windows };
|
||||
});
|
||||
}
|
||||
|
||||
/** Kimi Code's OAuth token and its managed-plan usage. */
|
||||
async function kimiQuota() {
|
||||
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code");
|
||||
const cred = readJson(path.join(home, "credentials", "kimi-code.json"));
|
||||
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
|
||||
const exp = Number(cred.expires_at) * 1000;
|
||||
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
|
||||
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
||||
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
|
||||
windows: [
|
||||
["5h", body?.usages?.limit_5h],
|
||||
["wk", body?.usages?.limit_7d],
|
||||
["mo", body?.usages?.limit_month_total],
|
||||
]
|
||||
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
|
||||
.map(([label, u]) => ({
|
||||
label,
|
||||
usedPercent: Number(u.used_ratio) * 100,
|
||||
resetsAt: isoOf(u.reset_time),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
|
||||
function openCodeGoKey() {
|
||||
const dir = path.join(
|
||||
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
|
||||
"opencode"
|
||||
);
|
||||
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
|
||||
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
|
||||
let conn;
|
||||
try {
|
||||
conn = new Database(path.join(dir, "opencode.db"), { readonly: true, fileMustExist: true });
|
||||
const rows = conn
|
||||
.prepare(
|
||||
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
|
||||
)
|
||||
.all();
|
||||
for (const r of rows) {
|
||||
const v = JSON.parse(r.value);
|
||||
if (typeof v?.key === "string" && v.key) return v.key;
|
||||
}
|
||||
} catch {
|
||||
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
|
||||
} finally {
|
||||
conn?.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openCodeGoQuota() {
|
||||
const key = openCodeGoKey();
|
||||
if (!key) return null;
|
||||
const who = { id: "opencode", active: true, plan: "Go" };
|
||||
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
|
||||
windows: [
|
||||
["5h", body?.usage?.rolling],
|
||||
["wk", body?.usage?.weekly],
|
||||
["mo", body?.usage?.monthly],
|
||||
]
|
||||
.filter(([, m]) => typeof m?.percent === "number")
|
||||
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
|
||||
}));
|
||||
}
|
||||
|
||||
async function quotas({ force = false } = {}) {
|
||||
const tools = [...(await claudeQuotas(force)), codexQuota()].filter(Boolean);
|
||||
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
|
||||
claudeQuotas(force),
|
||||
cached("cursor", force, cursorQuota),
|
||||
cached("antigravity", force, antigravityQuota),
|
||||
cached("grok", force, grokQuota),
|
||||
cached("kimi", force, kimiQuota),
|
||||
cached("opencode", force, openCodeGoQuota),
|
||||
]);
|
||||
const tools = [...claude, codexQuota(), antigravity, cursor, grok, kimi, opencode].filter(
|
||||
Boolean
|
||||
);
|
||||
return { tools, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@@ -270,4 +593,11 @@ function __resetCache() {
|
||||
lastGood.clear();
|
||||
}
|
||||
|
||||
module.exports = { quotas, codexWindow, codexAccount, windowLabel, __resetCache };
|
||||
module.exports = {
|
||||
quotas,
|
||||
codexWindow,
|
||||
codexAccount,
|
||||
windowLabel,
|
||||
agyVersionOk,
|
||||
__resetCache,
|
||||
};
|
||||
|
||||
+42
-1
@@ -26,6 +26,30 @@ const { execFileSync } = require("node:child_process");
|
||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
|
||||
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||
|
||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
||||
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
|
||||
// and dies when that cgroup is stopped (an update's restart under a unit
|
||||
// without KillMode=process, systemd-oomd, closing that app). So the FIRST
|
||||
// session, which starts the server, is created inside a transient scope of
|
||||
// its own: panes end only on Quit (`quit.js`) or with the machine. `--scope`
|
||||
// runs tmux as a child, so it keeps this process's environment.
|
||||
const realScopeImpl = (args) =>
|
||||
execFileSync(
|
||||
"systemd-run",
|
||||
[
|
||||
"--user",
|
||||
"--scope",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--description=AgentHub tmux server",
|
||||
"--",
|
||||
"tmux",
|
||||
...args,
|
||||
],
|
||||
EXEC_OPTS
|
||||
);
|
||||
let scopeImpl = realScopeImpl;
|
||||
|
||||
// Whether this tmux takes `=name` targets: true for tmux itself, false for
|
||||
// psmux (the WinGet `tmux` on Windows), null until a live session has shown
|
||||
// which. A fake exec in a test is real tmux unless it says otherwise.
|
||||
@@ -33,11 +57,17 @@ let exactTargets = null;
|
||||
|
||||
function __setExecImpl(fn, { exact = true } = {}) {
|
||||
execImpl = fn;
|
||||
scopeImpl = null; // a fake tmux never starts a real scope
|
||||
|
||||
exactTargets = exact;
|
||||
listed = null;
|
||||
}
|
||||
function __setScopeImpl(fn) {
|
||||
scopeImpl = fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||
scopeImpl = realScopeImpl;
|
||||
exactTargets = null;
|
||||
listed = null;
|
||||
}
|
||||
@@ -143,7 +173,17 @@ function newSession({ name, cwd, argv, env }) {
|
||||
// No argv = tmux's own default shell (a Windows Terminal pane).
|
||||
const cmd = argv.length ? ["--", ...argv] : [];
|
||||
listed = null;
|
||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd]);
|
||||
const args = ["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd];
|
||||
if (scopeImpl && process.platform === "linux" && !serverPid()) {
|
||||
try {
|
||||
scopeImpl(args);
|
||||
} catch {
|
||||
// No user manager (SSH without linger, no systemd): the plain start.
|
||||
if (!hasSession(name)) execImpl(args);
|
||||
}
|
||||
} else {
|
||||
execImpl(args);
|
||||
}
|
||||
try {
|
||||
enableMouse(name);
|
||||
} catch (err) {
|
||||
@@ -346,6 +386,7 @@ function serverVersion() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
__setScopeImpl,
|
||||
session,
|
||||
pane,
|
||||
hasSession,
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
* price; anything unknown collapses to the standard/global default so old
|
||||
* transcripts (which lack `speed` / `inference_geo` / `cache_creation`
|
||||
* breakdown / `server_tool_use`) price exactly as they did before.
|
||||
*
|
||||
* A request whose whole prompt (input + cache read + cache write) is above
|
||||
* LONG_CONTEXT_TOKENS is ALSO counted into the bucket's `long*` fields — a
|
||||
* subset of the totals, like `cacheWrite1h` — so cost can price that part at
|
||||
* the model's long-context rates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { LONG_CONTEXT_TOKENS } = require("./pricing-constants");
|
||||
|
||||
// Separator for composite bucket keys — U+0001 (SOH) cannot occur in a model id.
|
||||
const BUCKET_SEP = String.fromCharCode(1);
|
||||
|
||||
@@ -56,6 +63,13 @@ function emptyBucket(model, speed, geo, tier) {
|
||||
webSearch: 0, // server_tool_use.web_search_requests (billed per 1k)
|
||||
webFetch: 0, // server_tool_use.web_fetch_requests (free; tracked for visibility)
|
||||
codeExec: 0, // server_tool_use.code_execution_requests (time-billed; estimated)
|
||||
// Subsets of input/output/cacheRead/cacheWrite/cacheWrite1h spent by
|
||||
// requests whose prompt was above LONG_CONTEXT_TOKENS.
|
||||
longInput: 0,
|
||||
longOutput: 0,
|
||||
longCacheRead: 0,
|
||||
longCacheWrite: 0,
|
||||
longCacheWrite1h: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +90,11 @@ function extractUsageFields(usage) {
|
||||
webSearch: 0,
|
||||
webFetch: 0,
|
||||
codeExec: 0,
|
||||
longInput: 0,
|
||||
longOutput: 0,
|
||||
longCacheRead: 0,
|
||||
longCacheWrite: 0,
|
||||
longCacheWrite1h: 0,
|
||||
};
|
||||
}
|
||||
const cc =
|
||||
@@ -93,18 +112,42 @@ function extractUsageFields(usage) {
|
||||
usage.server_tool_use && typeof usage.server_tool_use === "object"
|
||||
? usage.server_tool_use
|
||||
: null;
|
||||
const input = usage.input_tokens || 0;
|
||||
const output = usage.output_tokens || 0;
|
||||
const cacheRead = usage.cache_read_input_tokens || 0;
|
||||
const long = input + cacheRead + cacheWrite > LONG_CONTEXT_TOKENS;
|
||||
return {
|
||||
input: usage.input_tokens || 0,
|
||||
output: usage.output_tokens || 0,
|
||||
cacheRead: usage.cache_read_input_tokens || 0,
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
cacheWrite1h,
|
||||
webSearch: stu ? stu.web_search_requests || 0 : 0,
|
||||
webFetch: stu ? stu.web_fetch_requests || 0 : 0,
|
||||
codeExec: stu ? stu.code_execution_requests || 0 : 0,
|
||||
longInput: long ? input : 0,
|
||||
longOutput: long ? output : 0,
|
||||
longCacheRead: long ? cacheRead : 0,
|
||||
longCacheWrite: long ? cacheWrite : 0,
|
||||
longCacheWrite1h: long ? cacheWrite1h : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A bucket's long-context subsets in `long_*_tokens` column order, or null
|
||||
* when it has none (the caller then skips the extra write).
|
||||
*/
|
||||
function longArgs(b) {
|
||||
const args = [
|
||||
b.longInput,
|
||||
b.longOutput,
|
||||
b.longCacheRead,
|
||||
b.longCacheWrite,
|
||||
b.longCacheWrite1h,
|
||||
].map((n) => n || 0);
|
||||
return args.some((n) => n > 0) ? args : null;
|
||||
}
|
||||
|
||||
/** Add the numeric fields of `src` into `target` in place. */
|
||||
function accumulateBucket(target, src) {
|
||||
target.input += src.input || 0;
|
||||
@@ -115,6 +158,11 @@ function accumulateBucket(target, src) {
|
||||
target.webSearch += src.webSearch || 0;
|
||||
target.webFetch += src.webFetch || 0;
|
||||
target.codeExec += src.codeExec || 0;
|
||||
target.longInput = (target.longInput || 0) + (src.longInput || 0);
|
||||
target.longOutput = (target.longOutput || 0) + (src.longOutput || 0);
|
||||
target.longCacheRead = (target.longCacheRead || 0) + (src.longCacheRead || 0);
|
||||
target.longCacheWrite = (target.longCacheWrite || 0) + (src.longCacheWrite || 0);
|
||||
target.longCacheWrite1h = (target.longCacheWrite1h || 0) + (src.longCacheWrite1h || 0);
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -127,4 +175,5 @@ module.exports = {
|
||||
emptyBucket,
|
||||
extractUsageFields,
|
||||
accumulateBucket,
|
||||
longArgs,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file tray.js — the AgentHub icon in the OS status area (GNOME top bar,
|
||||
* macOS menu bar, Windows notification area), like 9router's. The icon is its
|
||||
* own process (`scripts/tray.js`) so a server restart or a Quit never takes it
|
||||
* away — after a Quit it is how the user starts AgentHub again. A production
|
||||
* server keeps it alive (`startTrayWatch`): ON by default, OFF once the user
|
||||
* hides it (the flag `<data dir>/runtime/tray-off`, written by Settings →
|
||||
* System or the icon's own "Hide this icon"; nothing turns it back on but the
|
||||
* user). On Linux it waits for a graphical session — a server started at boot
|
||||
* has no DISPLAY until someone logs in. Never in server mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const CHECK_MS = 60_000;
|
||||
const MAX_FAILED_STARTS = 3;
|
||||
const HEARTBEAT_MS = 5_000;
|
||||
|
||||
const runtime = () => path.join(getDataDir(), "runtime");
|
||||
const offPath = () => path.join(runtime(), "tray-off");
|
||||
const pidPath = () => path.join(runtime(), "tray.json");
|
||||
|
||||
function isEnabled() {
|
||||
return !fs.existsSync(offPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* `{pid, root}` of the live tray process, or null. The icon touches its file
|
||||
* on every poll (`HEARTBEAT_MS`), so a file left by a crash or a reboot goes
|
||||
* stale and its pid — by then possibly another program's — is never signalled.
|
||||
*/
|
||||
function liveTray() {
|
||||
try {
|
||||
if (Date.now() - fs.statSync(pidPath()).mtimeMs > 6 * HEARTBEAT_MS) return null;
|
||||
const rec = JSON.parse(fs.readFileSync(pidPath(), "utf8"));
|
||||
process.kill(rec.pid, 0);
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePid(pid, root = ROOT) {
|
||||
fs.mkdirSync(runtime(), { recursive: true });
|
||||
fs.writeFileSync(pidPath(), JSON.stringify({ pid, root }), "utf8");
|
||||
}
|
||||
|
||||
function stopTray() {
|
||||
const rec = liveTray();
|
||||
if (!rec) return;
|
||||
try {
|
||||
process.kill(rec.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** The environment the icon needs to reach the desktop, or null when there is none. */
|
||||
function guiEnv() {
|
||||
if (process.platform !== "linux") return {};
|
||||
const env = require("./desktop-env").desktopEnv();
|
||||
return env.DISPLAY || env.WAYLAND_DISPLAY ? env : null;
|
||||
}
|
||||
|
||||
function status() {
|
||||
const { isServerMode } = require("./auth");
|
||||
return {
|
||||
available: !isServerMode() && guiEnv() !== null,
|
||||
enabled: isEnabled(),
|
||||
running: liveTray() !== null,
|
||||
};
|
||||
}
|
||||
|
||||
let failedStarts = 0;
|
||||
|
||||
/**
|
||||
* Starts the icon when it should be up and is not. `replace` (once, at server
|
||||
* start) retires an icon still running from another install root — the
|
||||
* previous plugin build, whose cache directory is about to be pruned.
|
||||
*/
|
||||
function ensureTray({ replace = false, spawn = require("child_process").spawn } = {}) {
|
||||
if (!isEnabled() || failedStarts >= MAX_FAILED_STARTS) return "skipped";
|
||||
const live = liveTray();
|
||||
if (live && !(replace && live.root !== ROOT)) return "running";
|
||||
const env = guiEnv();
|
||||
if (!env) return "no-gui";
|
||||
if (live) stopTray();
|
||||
const started = Date.now();
|
||||
const child = spawn(process.execPath, [path.join(ROOT, "scripts", "tray.js")], {
|
||||
cwd: ROOT,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
child.on("error", () => failedStarts++);
|
||||
// An icon that dies at once (no tray host, a binary the OS refuses) is not
|
||||
// retried forever: three quick deaths and this server leaves it alone.
|
||||
child.on("exit", () => {
|
||||
if (Date.now() - started < 10_000) failedStarts++;
|
||||
});
|
||||
child.unref();
|
||||
return "started";
|
||||
}
|
||||
|
||||
function setEnabled(on, opts) {
|
||||
if (on) {
|
||||
fs.rmSync(offPath(), { force: true });
|
||||
failedStarts = 0;
|
||||
ensureTray(opts);
|
||||
} else {
|
||||
fs.mkdirSync(runtime(), { recursive: true });
|
||||
fs.writeFileSync(offPath(), `${new Date().toISOString()}\n`, "utf8");
|
||||
stopTray();
|
||||
}
|
||||
return status();
|
||||
}
|
||||
|
||||
function startTrayWatch() {
|
||||
if (process.env.NODE_ENV !== "production" || require("./auth").isServerMode()) return;
|
||||
try {
|
||||
ensureTray({ replace: true });
|
||||
} catch {
|
||||
/* an icon is never worth a server crash */
|
||||
}
|
||||
setInterval(() => {
|
||||
try {
|
||||
ensureTray();
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}, CHECK_MS).unref();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isEnabled,
|
||||
liveTray,
|
||||
writePid,
|
||||
stopTray,
|
||||
status,
|
||||
ensureTray,
|
||||
setEnabled,
|
||||
startTrayWatch,
|
||||
offPath,
|
||||
pidPath,
|
||||
HEARTBEAT_MS,
|
||||
};
|
||||
Reference in New Issue
Block a user