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:
@@ -59,7 +59,7 @@ describe("/api/ai-tools", () => {
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
||||
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("/api/ai-tools", () => {
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
||||
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
@@ -112,10 +112,10 @@ describe("/api/ai-tools", () => {
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
||||
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
@@ -132,9 +132,9 @@ describe("lanes on catalog tools", () => {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "gemini");
|
||||
assert.equal(r.body.lane.tool, "opencode");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
@@ -212,8 +212,8 @@ describe("install / update / icons", () => {
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||
// opencode was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
|
||||
@@ -241,7 +241,7 @@ describe("install / update / icons", () => {
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.gemini.npmManaged, false);
|
||||
assert.equal(v.body.opencode.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
|
||||
@@ -82,6 +82,19 @@ describe("commit-message", () => {
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("with paths, reads exactly those files' working-tree changes, untracked included", async () => {
|
||||
write("a.txt", "one\nticked edit\n");
|
||||
write("new.txt", "brand new\n");
|
||||
write("other.txt", "not ticked\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n');
|
||||
const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]);
|
||||
assert.match(input, /ticked edit/);
|
||||
assert.match(input, /brand new/);
|
||||
assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/);
|
||||
await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" });
|
||||
await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @file The Changes panel's JetBrains-style commit against a REAL repo:
|
||||
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
|
||||
* content (an untracked one included) and leave every other change — staged
|
||||
* or not — where it was; a failed commit puts a ticked untracked file back
|
||||
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
|
||||
* line in the workspace's own .gitignore, only for an untracked path, never
|
||||
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
|
||||
* the ticked change blocks, through a temporary index, the real index synced
|
||||
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
|
||||
* file's history with its parents rewritten so the graph joins up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { blameFile, listCommits } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
|
||||
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
write("a.txt", "a\n");
|
||||
write("b.txt", "b\n");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
});
|
||||
|
||||
describe("commit with paths", () => {
|
||||
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
|
||||
write("a.txt", "a2\n"); // ticked, unstaged
|
||||
write("b.txt", "b2\n");
|
||||
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
|
||||
write("c.txt", "c2\n"); // not ticked
|
||||
write("new.txt", "n\n"); // ticked, untracked
|
||||
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
|
||||
assert.deepEqual(
|
||||
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
|
||||
["a.txt", "new.txt"]
|
||||
);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^M {2}b\.txt$/m);
|
||||
assert.match(status(), /^ M c\.txt$/m);
|
||||
});
|
||||
|
||||
it("commits a partly staged file's working-tree content, a deletion too", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
write("a.txt", "staged\nand more\n");
|
||||
fs.rmSync(path.join(REPO, "b.txt"));
|
||||
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
|
||||
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
|
||||
assert.ok(!inHead().includes("b.txt"));
|
||||
assert.equal(status(), "");
|
||||
});
|
||||
|
||||
it("refuses an empty list and a path git does not report changed", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
|
||||
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
|
||||
assert.match(status(), /^\?\? new\.txt$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("amend with paths", () => {
|
||||
it("an empty list rewords only, leaving the staged change out", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("add", "a.txt");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a\n");
|
||||
assert.match(status(), /^M {2}a\.txt$/m);
|
||||
});
|
||||
|
||||
it("folds the ticked files in, and refuses an empty list with no message", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
|
||||
code: "ENOCHANGES",
|
||||
});
|
||||
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
|
||||
assert.ok(inHead().includes("new.txt"));
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignore", () => {
|
||||
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
|
||||
write(".gitignore", "node_modules");
|
||||
write("debug*.log", "x\n");
|
||||
write("note.md", "x\n");
|
||||
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
|
||||
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
|
||||
assert.doesNotMatch(status(), /debug|note/);
|
||||
});
|
||||
|
||||
it("creates the file when there is none, in a subdirectory too", async () => {
|
||||
fs.mkdirSync(path.join(REPO, "dir"));
|
||||
write("dir/x y.tmp", "x\n");
|
||||
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
|
||||
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
|
||||
assert.doesNotMatch(status(), /x y\.tmp/);
|
||||
});
|
||||
|
||||
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
|
||||
write("new.txt", "n\n");
|
||||
const outside = path.join(ROOT, "outside-ignore");
|
||||
fs.writeFileSync(outside, "");
|
||||
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.equal(fs.readFileSync(outside, "utf8"), "");
|
||||
});
|
||||
});
|
||||
|
||||
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
|
||||
const headers = (file) =>
|
||||
g("diff", "-U3", "HEAD", "--", file)
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("@@ "));
|
||||
|
||||
describe("commit with hunks", () => {
|
||||
beforeEach(() => {
|
||||
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
|
||||
g("add", "long.txt");
|
||||
g("commit", "-qm", "long");
|
||||
});
|
||||
const edit = (pairs) => {
|
||||
let text = read("long.txt");
|
||||
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
|
||||
write("long.txt", text);
|
||||
};
|
||||
|
||||
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const [first] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "part",
|
||||
context: 3,
|
||||
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^three$/m);
|
||||
assert.match(committed, /^25$/m);
|
||||
assert.match(read("long.txt"), /^twentyfive$/m);
|
||||
// The index followed the commit: the rest is an unstaged change only.
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["5", "five"],
|
||||
]);
|
||||
write("a.txt", "a2\n");
|
||||
const [only] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "block",
|
||||
context: 3,
|
||||
paths: ["a.txt"],
|
||||
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^3$/m);
|
||||
assert.match(committed, /^five$/m);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
assert.doesNotMatch(status(), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
|
||||
edit([["3", "three"]]);
|
||||
write("new.txt", "n\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EUNTRACKED" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
|
||||
}),
|
||||
{ code: "EBADPATH" }
|
||||
);
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("amends with only the ticked hunk", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
const [, second] = headers("long.txt");
|
||||
await runGitAction(REPO, "amend", {
|
||||
sha: head,
|
||||
context: 3,
|
||||
paths: [],
|
||||
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
|
||||
});
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^twentyfive$/m);
|
||||
assert.match(committed, /^3$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blameFile", () => {
|
||||
it("names each line's commit, and none for a line not committed yet", async () => {
|
||||
write("a.txt", "a\nnew line\n");
|
||||
const blame = await blameFile(REPO, "a.txt");
|
||||
assert.equal(blame.lines.length, 2);
|
||||
const first = blame.commits[blame.lines[0]];
|
||||
assert.equal(first.summary, "init");
|
||||
assert.equal(first.author, "t");
|
||||
assert.equal(blame.lines[1], null);
|
||||
});
|
||||
|
||||
it("refuses a path outside the lane and a secret file", async () => {
|
||||
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
|
||||
fs.mkdirSync(path.join(REPO, ".claude"));
|
||||
write(".claude/settings.local.json", "{}\n");
|
||||
g("add", "-f", ".claude/settings.local.json");
|
||||
g("commit", "-qm", "secret");
|
||||
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file's history", () => {
|
||||
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("commit", "-qam", "a2");
|
||||
write("b.txt", "b2\n");
|
||||
g("commit", "-qam", "b only");
|
||||
write("a.txt", "a3\n");
|
||||
g("commit", "-qam", "a3");
|
||||
const commits = await listCommits(REPO, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
commits.map((c) => c.subject),
|
||||
["a3", "a2", "init"]
|
||||
);
|
||||
assert.deepEqual(commits[0].parents, [commits[1].sha]);
|
||||
});
|
||||
});
|
||||
@@ -141,3 +141,12 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
assert.equal(isInsideContainer(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOOK_TYPES", () => {
|
||||
it("is the event list the plugin ships, so the hook status checks every one", () => {
|
||||
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
|
||||
const plugin = require("../../.claude-plugin/plugin.json");
|
||||
assert.deepEqual([...exported].sort(), Object.keys(plugin.hooks).sort());
|
||||
assert.deepEqual([...exported].sort(), [...HOOK_TYPES].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,16 @@ describe("importing a .codex-pet bundle", () => {
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"pet.json": { spriteVersionNumber: 2 },
|
||||
"spritesheet.webp": webp(1536, 2288),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
|
||||
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
@@ -205,6 +205,37 @@ describe("fast path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fast path after a Quit (issue #65)", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
it("starts nothing while the stopped flag is set, and starts again once it is gone", () => {
|
||||
const pkg = require("../../package.json");
|
||||
const { markQuit, clearQuit } = require("../lib/quit");
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = TMP_HOME; // the real unit may name another root: stale
|
||||
try {
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
boot.writeState(
|
||||
{
|
||||
pluginVersion: pkg.version,
|
||||
depsHash: boot.depsHash(),
|
||||
pluginRoot: path.resolve(__dirname, "..", ".."),
|
||||
},
|
||||
RT
|
||||
);
|
||||
// No server is live: without the flag the worker would start one.
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
markQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), true);
|
||||
clearQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap lock", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
|
||||
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
|
||||
* pricing modifiers (fast mode, US data residency, Batch API).
|
||||
* pricing modifiers (fast mode, US data residency, Batch API), and the
|
||||
* long-context subsets priced at a rule's long_* rates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -363,3 +364,72 @@ describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context tier (prompt > 100k tokens)", () => {
|
||||
// Haiku 5.5: every rate x5 above a 100k-token prompt.
|
||||
const HAIKU = [
|
||||
{
|
||||
model_pattern: "claude-haiku-5-5%",
|
||||
display_name: "Claude Haiku 5.5",
|
||||
input_per_mtok: 0.1,
|
||||
output_per_mtok: 0.5,
|
||||
cache_read_per_mtok: 0.01,
|
||||
cache_write_per_mtok: 0.125,
|
||||
cache_write_1h_per_mtok: 0.2,
|
||||
long_input_per_mtok: 0.5,
|
||||
long_output_per_mtok: 2.5,
|
||||
},
|
||||
];
|
||||
const haiku = (extra) => bucket({ model: "claude-haiku-5-5", ...extra });
|
||||
|
||||
it("counts a request into the long subsets by its whole prompt: input + cache read + cache write", () => {
|
||||
const short = extractUsageFields({ input_tokens: 100_000, output_tokens: 5 });
|
||||
assert.equal(short.longInput, 0);
|
||||
const long = extractUsageFields({
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 90_000,
|
||||
cache_creation_input_tokens: 10_000,
|
||||
cache_creation: { ephemeral_5m_input_tokens: 4_000, ephemeral_1h_input_tokens: 6_000 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[
|
||||
long.longInput,
|
||||
long.longOutput,
|
||||
long.longCacheRead,
|
||||
long.longCacheWrite,
|
||||
long.longCacheWrite1h,
|
||||
],
|
||||
[10, 5, 90_000, 10_000, 6_000]
|
||||
);
|
||||
});
|
||||
|
||||
it("prices the long subset at the long rates, cache rates scaled by the input ratio", () => {
|
||||
const all = {
|
||||
input_tokens: M,
|
||||
output_tokens: M,
|
||||
cache_read_tokens: M,
|
||||
cache_write_tokens: 2 * M,
|
||||
cache_write_1h_tokens: M,
|
||||
};
|
||||
const long = Object.fromEntries(Object.entries(all).map(([k, v]) => [`long_${k}`, v]));
|
||||
// 0.5 + 2.5 + 0.05 + 0.625 + 1.0
|
||||
assert.equal(calculateCost([haiku({ ...all, ...long })], HAIKU).total_cost, 4.675);
|
||||
});
|
||||
|
||||
it("prices only the long part of a bucket at the long rates", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: 2 * M, long_input_tokens: M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.6);
|
||||
assert.equal(r.breakdown[0].long_input_tokens, M);
|
||||
});
|
||||
|
||||
it("never prices more long tokens than the bucket holds", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: M, long_input_tokens: 3 * M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.5);
|
||||
});
|
||||
|
||||
it("prices a long subset at the standard rates when the rule has no long rates", () => {
|
||||
const r = calculateCost([bucket({ input_tokens: M, long_input_tokens: M })], RULES);
|
||||
assert.equal(r.total_cost, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* before it is used, the sync's own rows follow a rate change while a user's
|
||||
* row and a deleted pattern are never touched, a bad list or a failed fetch
|
||||
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
|
||||
* `openai` entry too.
|
||||
* `openai` entry too, and a Claude entry's above-100k rates fill the long_*
|
||||
* columns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -428,3 +429,34 @@ describe("pricing sync", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context rates", () => {
|
||||
it("takes Anthropic's above-100k input/output rates into the long columns", () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
const r = toRow("claude-opus-9-9", e);
|
||||
assert.equal(r.longIn, 20);
|
||||
assert.equal(r.longOut, 100);
|
||||
assert.equal(toRow("claude-opus-9-9", LIST["claude-opus-9-9"]).longIn, 0);
|
||||
});
|
||||
|
||||
it("writes them, and fills them into a sync row that predates them", async () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
useModel("claude-opus-9-9");
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => ({ "claude-opus-9-9": LIST["claude-opus-9-9"] }),
|
||||
});
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 0);
|
||||
_resetCache();
|
||||
await syncPricing(db, { fetchList: async () => ({ "claude-opus-9-9": e }) });
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 20);
|
||||
assert.equal(row("claude-opus-9-9%").long_output_per_mtok, 100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -651,7 +651,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
|
||||
for (const name of ["agy", "grok", "goose", "command-code"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -734,15 +734,11 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
|
||||
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||
at("gemini"),
|
||||
"--prompt-interactive",
|
||||
" -x y",
|
||||
]);
|
||||
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
|
||||
});
|
||||
|
||||
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
|
||||
* POST /api/settings/quit): the stopped flag, stopping through the service
|
||||
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
|
||||
* and the route killing every lane pane before it stops. tmux runs only in the
|
||||
* isolated server `npm run test:server` sets up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-quit-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const quit = require("../lib/quit");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("stopped flag", () => {
|
||||
it("is set by markQuit and cleared by clearQuit, in the data dir's runtime", () => {
|
||||
assert.equal(quit.flagPath(), path.join(TMP, "runtime", "stopped"));
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.markQuit();
|
||||
assert.equal(quit.isQuit(), true);
|
||||
quit.clearQuit();
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.clearQuit(); // already gone: fine
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopServer", () => {
|
||||
const fakeSvc = (kind, active) => ({
|
||||
kind,
|
||||
systemctlAvailable: () => true,
|
||||
unitIsOurs: () => true,
|
||||
unitIsActive: () => active,
|
||||
});
|
||||
const spawned = [];
|
||||
const spawn = (cmd, args) => {
|
||||
spawned.push([cmd, ...args].join(" "));
|
||||
return { unref() {} };
|
||||
};
|
||||
|
||||
it("SIGTERMs itself when no unit of ours is running", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", false), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 1);
|
||||
assert.deepEqual(spawned, []);
|
||||
});
|
||||
|
||||
it("stops a running unit through systemd, never by a signal Restart=always undoes", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", true), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 0);
|
||||
assert.deepEqual(spawned.splice(0), ["systemctl --user stop --no-block agenthub.service"]);
|
||||
});
|
||||
|
||||
it("boots a running LaunchAgent out", { skip: process.platform === "win32" }, () => {
|
||||
quit.stopServer({ svc: fakeSvc("launchd", true), spawn, kill: () => {} });
|
||||
assert.match(spawned.splice(0)[0], /^launchctl bootout gui\/\d+\/io\.smartgift\.agenthub$/);
|
||||
});
|
||||
});
|
||||
|
||||
// Only inside the runner's private tmux server: never the developer's own.
|
||||
const isolatedTmux = !!process.env.TMUX_TMPDIR && !process.env.TMUX;
|
||||
let hasTmux = false;
|
||||
try {
|
||||
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
||||
hasTmux = true;
|
||||
} catch {
|
||||
/* not installed */
|
||||
}
|
||||
|
||||
describe(
|
||||
"POST /api/settings/quit",
|
||||
{ skip: !(isolatedTmux && hasTmux) && "needs the runner's isolated tmux" },
|
||||
() => {
|
||||
let server;
|
||||
let base;
|
||||
let stops = 0;
|
||||
const realStop = quit.stopServer;
|
||||
|
||||
before(async () => {
|
||||
quit.stopServer = () => stops++; // this process is the test runner
|
||||
const { createApp, startServer } = require("../index");
|
||||
server = await startServer(createApp(), 0);
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
quit.stopServer = realStop;
|
||||
server?.close();
|
||||
require("../db").db.close();
|
||||
});
|
||||
|
||||
it("closes every lane pane, sets the flag, then stops the server", async () => {
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001-2", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "not-ours", "sleep 60"]);
|
||||
try {
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const req = http.request(`${base}/api/settings/quit`, { method: "POST" }, (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => resolve(JSON.parse(text)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
assert.deepEqual(body, { ok: true, killed: 2 });
|
||||
assert.equal(quit.isQuit(), true);
|
||||
const left = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.deepEqual(left.trim().split("\n"), ["not-ours"]);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(stops, 1);
|
||||
} finally {
|
||||
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -4,7 +4,11 @@
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed, and never shown
|
||||
* with the usage of a token that belongs to a different account.
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +16,19 @@ const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
// No test may read (or send) the machine's real Cursor login.
|
||||
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
}
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
|
||||
fs.writeFileSync(
|
||||
path.join(home, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email: "a@b.c",
|
||||
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: jwt({ exp: 1_800_000_000 }),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
plan: "plus",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
// Thắng's identity, Victor's token.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-v",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
subscriptionType: "max",
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
|
||||
const auth = path.join(root, "config", "cursor", "auth.json");
|
||||
fs.mkdirSync(path.dirname(auth), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "cursor"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "cursor", "cli-config.json"),
|
||||
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
|
||||
);
|
||||
const prev = {
|
||||
xdg: process.env.XDG_CONFIG_HOME,
|
||||
dir: process.env.AGENTHUB_CURSOR_DIR,
|
||||
fetch: global.fetch,
|
||||
};
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
|
||||
const asked = [];
|
||||
global.fetch = async (url, opts) => {
|
||||
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
|
||||
asked.push(`${url} ${opts.headers.Cookie}`);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
billingCycleEnd: "2099-01-01T00:00:00.000Z",
|
||||
membershipType: "pro",
|
||||
individualUsage: { plan: { totalPercentUsed: 9.5 } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await cursor(), undefined); // not logged in: no row
|
||||
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await cursor(), {
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@d.e",
|
||||
plan: "pro",
|
||||
expiresAt: new Date(
|
||||
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
|
||||
).toISOString(),
|
||||
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepStrictEqual(asked, [
|
||||
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
|
||||
]);
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(asked.length, 1);
|
||||
} finally {
|
||||
for (const [k, v] of [
|
||||
["XDG_CONFIG_HOME", prev.xdg],
|
||||
["AGENTHUB_CURSOR_DIR", prev.dir],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
|
||||
const log = path.join(bin, "calls");
|
||||
const version = path.join(bin, "version");
|
||||
fs.writeFileSync(version, "1.2.17");
|
||||
const bucket = (id, window, rf) => ({
|
||||
id,
|
||||
window,
|
||||
remaining_fraction: rf,
|
||||
reset_time: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "out.json"),
|
||||
JSON.stringify({
|
||||
status: "SUCCESS",
|
||||
num_turns: 0,
|
||||
command: {
|
||||
name: "usage",
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
name: "Gemini Models",
|
||||
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
|
||||
},
|
||||
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "agy"),
|
||||
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${prevPath}`;
|
||||
delete process.env.AGENTHUB_AGY_USAGE;
|
||||
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
|
||||
try {
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
const row = await agy();
|
||||
assert.strictEqual(row.active, true);
|
||||
assert.deepStrictEqual(row.windows, [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
|
||||
{
|
||||
group: "Gemini Models",
|
||||
label: "wk",
|
||||
usedPercent: 25,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
group: "Claude and GPT models",
|
||||
label: "5h",
|
||||
usedPercent: 50,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
|
||||
const [cwd, ...args] = calls[1].split(" ");
|
||||
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
|
||||
assert.notStrictEqual(cwd, process.cwd());
|
||||
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
|
||||
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
|
||||
fs.writeFileSync(version, "1.1.10");
|
||||
fs.writeFileSync(log, "");
|
||||
__resetCache();
|
||||
assert.strictEqual(await agy(), undefined);
|
||||
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch stub answering per host, recording every `url auth` it was asked. */
|
||||
function stubFetch(answers) {
|
||||
const asked = [];
|
||||
const prev = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
|
||||
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
|
||||
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
|
||||
const [status, body] = hit[1];
|
||||
return { ok: status < 300, status, json: async () => body };
|
||||
};
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "kimi-code.json");
|
||||
const f = stubFetch({
|
||||
"https://api.kimi.com/coding/v1/usages": [
|
||||
200,
|
||||
{
|
||||
usages: {
|
||||
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
|
||||
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
|
||||
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
|
||||
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await kimi(), undefined);
|
||||
const exp = Math.floor(Date.now() / 1000) + 600;
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
|
||||
);
|
||||
__resetCache();
|
||||
const row = await kimi();
|
||||
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
|
||||
assert.deepStrictEqual(
|
||||
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
|
||||
[
|
||||
["5h", 30],
|
||||
["wk", 20],
|
||||
["mo", 40],
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("kimi")),
|
||||
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @file server-mode-user-admin.test.js
|
||||
* @description Managing accounts from the dashboard (Users page): only an
|
||||
* admin may create a user, change a role, disable/enable, reset a password or
|
||||
* give a workspace, every change needs the admin's OWN password again
|
||||
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
|
||||
* answer carries a password hash, each change lands in the audit log, and in
|
||||
* local mode — no login at all — the routes refuse.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-admin-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
async function call(method, p, { cookie, body, origin } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
|
||||
ids.lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
|
||||
.run().lastInsertRowid;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("managing accounts from the dashboard", () => {
|
||||
it("lets an admin create a user, with their own password again", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: {
|
||||
username: "neo",
|
||||
password: "neo-pass-123",
|
||||
role: "member",
|
||||
currentPassword: BOSS_PASS,
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.deepEqual(
|
||||
{ username: r.body.user.username, role: r.body.user.role },
|
||||
{ username: "neo", role: "member" }
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
|
||||
|
||||
const dup = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(dup.status, 409);
|
||||
assert.equal(dup.body.error.code, "EEXISTS");
|
||||
const short = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(short.status, 400);
|
||||
assert.equal(short.body.error.code, "EBADPASSWORD");
|
||||
});
|
||||
|
||||
it("refuses without the admin's password, or with a wrong one", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const currentPassword of [undefined, "not-the-password"]) {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREAUTH");
|
||||
}
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
|
||||
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
|
||||
const ana = await call("POST", "/api/users", {
|
||||
cookie: await login("ana", "analyst-pass-1"),
|
||||
body,
|
||||
});
|
||||
assert.equal(ana.status, 403);
|
||||
const mia = await call("POST", "/api/users", {
|
||||
cookie: await login("mia", "member-pass-1"),
|
||||
body: { ...body, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(mia.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("refuses a write from another origin", async () => {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: await login("boss", BOSS_PASS),
|
||||
origin: "http://evil.example",
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("changes a role, disables and enables, and resets a password", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const neo = auth.findUser("neo");
|
||||
const role = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(role.status, 200);
|
||||
assert.equal(auth.findUser("neo").role, "analyst");
|
||||
|
||||
const neoLogin = await login("neo", "neo-pass-123");
|
||||
const off = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: true, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(off.status, 200);
|
||||
assert.equal(off.body.user.disabled, true);
|
||||
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
|
||||
await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: false, currentPassword: BOSS_PASS },
|
||||
});
|
||||
|
||||
const pw = await call("POST", `/api/users/${neo.id}/password`, {
|
||||
cookie: boss,
|
||||
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(pw.status, 200);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null);
|
||||
assert.ok(await login("neo", "neo-new-pass-9"));
|
||||
|
||||
const bad = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "root", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const none = await call("PATCH", "/api/users/9999", {
|
||||
cookie: boss,
|
||||
body: { role: "member", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("never lets an admin demote or disable themselves", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const body of [{ role: "member" }, { disabled: true }]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { ...body, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ESELF");
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
});
|
||||
|
||||
it("gives a workspace to a user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
|
||||
ids.mia
|
||||
);
|
||||
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: 9999, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("writes every change to the audit log", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
||||
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
|
||||
const paths = mine.map((e) => e.path).join("\n");
|
||||
assert.match(paths, /create neo/);
|
||||
assert.match(paths, /role analyst/);
|
||||
assert.match(paths, /disable/);
|
||||
assert.match(paths, /password reset/);
|
||||
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
|
||||
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
|
||||
});
|
||||
|
||||
it("throttles wrong confirmation passwords", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
let last;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
last = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: "guess-" + i },
|
||||
});
|
||||
}
|
||||
assert.equal(last.status, 429);
|
||||
assert.equal(last.body.error.code, "ETOOMANY");
|
||||
assert.equal(auth.findUser("mia").role, "member");
|
||||
});
|
||||
});
|
||||
|
||||
describe("in local mode", () => {
|
||||
it("refuses every account change — there is no login to check", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
const r = await call("POST", "/api/users", {
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "ELOCALMODE");
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,15 @@ describe("systemd-service — unit content", linuxOnly, () => {
|
||||
assert.doesNotMatch(body, /^Restart=on-failure$/m);
|
||||
});
|
||||
|
||||
it("stops only the server process, so an update never takes the lane panes with it", () => {
|
||||
// tmux forks its server from whichever process first asks for a session —
|
||||
// the dashboard, after a boot with autostart on — and that server sits in
|
||||
// this unit's cgroup. The default KillMode=control-group would kill it,
|
||||
// and every running agent, on each update's restart.
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /^KillMode=process$/m);
|
||||
});
|
||||
|
||||
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /agenthub-managed-unit/);
|
||||
|
||||
@@ -57,6 +57,53 @@ describe("tmux wrapper", () => {
|
||||
assert.equal(tmux.liveSessions(), null);
|
||||
});
|
||||
|
||||
describe(
|
||||
"the tmux server outlives the dashboard (Linux)",
|
||||
{ skip: process.platform !== "linux" },
|
||||
() => {
|
||||
// Fake tmux: no server until a new-session, then every call succeeds.
|
||||
const fake =
|
||||
(calls, { running = false } = {}) =>
|
||||
(args) => {
|
||||
calls.push(["tmux", ...args].join(" "));
|
||||
if (args[0] === "display-message" && !running)
|
||||
throw Object.assign(new Error("no server"), { status: 1 });
|
||||
if (args[0] === "has-session" && !running)
|
||||
throw Object.assign(new Error("no session"), { status: 1 });
|
||||
return args[0] === "display-message" ? "4242\n" : "";
|
||||
};
|
||||
|
||||
it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" ")));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(
|
||||
calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude")
|
||||
);
|
||||
assert.ok(!calls.some((c) => c.startsWith("tmux new-session")));
|
||||
});
|
||||
|
||||
it("adds a session to a running server directly", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls, { running: true }));
|
||||
tmux.__setScopeImpl(() => assert.fail("a running server needs no scope"));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
|
||||
it("starts it plainly when no systemd user manager answers", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl(() => {
|
||||
throw Object.assign(new Error("Failed to connect to bus"), { status: 1 });
|
||||
});
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "new-session") return "";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics.
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
|
||||
* the long-context subsets written beside it (high-water mark, exact reset).
|
||||
*
|
||||
* The effective total for a bucket is `live + baseline`. It must behave as a
|
||||
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
|
||||
@@ -121,3 +122,38 @@ describe("replaceTokenUsage — high-water-mark baseline", () => {
|
||||
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context subsets", () => {
|
||||
const { writeSessionTokens } = require("../../scripts/import-history");
|
||||
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
|
||||
const longRow = (sid) =>
|
||||
db
|
||||
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
|
||||
.get(sid);
|
||||
const bucketOf = (...usages) => {
|
||||
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
|
||||
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
|
||||
return { k: b };
|
||||
};
|
||||
|
||||
before(() => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
|
||||
).run();
|
||||
});
|
||||
|
||||
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
|
||||
const short = { input_tokens: 1_000 };
|
||||
const long = { input_tokens: 150_000 };
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
|
||||
// A smaller-scope writer never takes the subset back.
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short));
|
||||
assert.equal(longRow("long-1").long_input_tokens, 150_000);
|
||||
});
|
||||
|
||||
it("lets the exact reconcile path clear a stale subset", () => {
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests the status-area icon's keeper (server/lib/tray.js): on by
|
||||
* default, never started once the user hid it, one icon at a time, a stale
|
||||
* heartbeat never trusted (its pid may be another program's by now), an icon
|
||||
* from another install root replaced only at server start, and no retry loop
|
||||
* for an icon that keeps dying. No real icon is spawned.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tray-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DISPLAY = process.env.DISPLAY || ":99"; // guiEnv() on Linux
|
||||
|
||||
const tray = require("../lib/tray");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
function fakeSpawn() {
|
||||
const calls = [];
|
||||
const spawn = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
return child;
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
describe("tray keeper", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(TMP, "runtime"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is on by default and starts scripts/tray.js detached", () => {
|
||||
assert.equal(tray.isEnabled(), true);
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "started");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].args[0], /scripts[\\/]tray\.js$/);
|
||||
assert.equal(calls[0].opts.detached, true);
|
||||
});
|
||||
|
||||
it("starts nothing once the user hid it", () => {
|
||||
fs.mkdirSync(path.dirname(tray.offPath()), { recursive: true });
|
||||
fs.writeFileSync(tray.offPath(), "x");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("leaves a live icon alone, and replaces one from another root only when asked", () => {
|
||||
tray.writePid(process.pid, "/some/other/root");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
tray.writePid(process.pid); // this root: nothing to replace
|
||||
assert.equal(tray.ensureTray({ spawn, replace: true }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("does not trust a heartbeat that went stale", () => {
|
||||
tray.writePid(process.pid);
|
||||
const old = new Date(Date.now() - 10 * tray.HEARTBEAT_MS);
|
||||
fs.utimesSync(tray.pidPath(), old, old);
|
||||
assert.equal(tray.liveTray(), null);
|
||||
});
|
||||
|
||||
it("writes and clears the off flag", () => {
|
||||
assert.equal(tray.setEnabled(false).enabled, false);
|
||||
assert.equal(fs.existsSync(tray.offPath()), true);
|
||||
tray.writePid(999_999_999); // no such process: nothing to signal, nothing to trust
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.setEnabled(true, { spawn }).enabled, true);
|
||||
assert.equal(fs.existsSync(tray.offPath()), false);
|
||||
assert.equal(calls.length, 1, "turning it on shows the icon at once");
|
||||
});
|
||||
|
||||
it("gives up on an icon that keeps dying at once", () => {
|
||||
const spawned = [];
|
||||
const spawn = () => {
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
spawned.push(child);
|
||||
return child;
|
||||
};
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (tray.ensureTray({ spawn }) === "started") spawned.at(-1).emit("exit", 1);
|
||||
}
|
||||
assert.ok(spawned.length <= 4, `retried ${spawned.length} times`);
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": "gemini",
|
||||
"label": "Gemini",
|
||||
"detect": [
|
||||
"gemini"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "flag",
|
||||
"flag": "--prompt-interactive"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model",
|
||||
"vendor": "google"
|
||||
},
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
|
||||
"faviconDomain": "gemini.google.com",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@google/gemini-cli"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.gemini/settings.json",
|
||||
"format": "jsonc"
|
||||
},
|
||||
{
|
||||
"path": "~/.gemini/GEMINI.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok Build",
|
||||
"detect": [
|
||||
"grok"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"bypass": [
|
||||
"--permission-mode",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"homepageUrl": "https://x.ai/cli",
|
||||
"faviconDomain": "x.ai",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://x.ai/cli/install.sh"
|
||||
},
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update"
|
||||
]
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.grok/config.toml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"id": "hermes",
|
||||
"label": "Hermes",
|
||||
"detect": [
|
||||
"hermes"
|
||||
],
|
||||
"args": [
|
||||
"--tui"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "after-start",
|
||||
"delayMs": 3000
|
||||
},
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://hermes-agent.nousresearch.com/docs/",
|
||||
"faviconDomain": "nousresearch.com",
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update",
|
||||
"--yes"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "kimi",
|
||||
"label": "Kimi Code",
|
||||
"detect": [
|
||||
"kimi"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "after-start"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"bypass": [
|
||||
"--auto"
|
||||
],
|
||||
"homepageUrl": "https://www.kimi.com/code/docs/",
|
||||
"faviconDomain": "kimi.com",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://code.kimi.com/kimi-code/install.sh"
|
||||
},
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"upgrade",
|
||||
"-y"
|
||||
]
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.kimi-code/config.toml"
|
||||
},
|
||||
{
|
||||
"path": "~/.kimi-code/tui.toml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"id": "openclaw",
|
||||
"label": "OpenClaw",
|
||||
"detect": [
|
||||
"openclaw"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "after-start",
|
||||
"delayMs": 3000
|
||||
},
|
||||
"homepageUrl": "https://github.com/openclaw/openclaw",
|
||||
"faviconDomain": "openclaw.ai",
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update",
|
||||
"--yes"
|
||||
]
|
||||
}
|
||||
}
|
||||
+90
-1
@@ -239,6 +239,14 @@ db.exec(`
|
||||
baseline_web_search INTEGER NOT NULL DEFAULT 0,
|
||||
baseline_web_fetch INTEGER NOT NULL DEFAULT 0,
|
||||
baseline_code_execution INTEGER NOT NULL DEFAULT 0,
|
||||
-- Subsets of the token columns spent by requests whose prompt was above
|
||||
-- LONG_CONTEXT_TOKENS (server/lib/pricing-constants.js), priced at the
|
||||
-- rule's long_* rates. Kept at their high-water mark like the hourly rows.
|
||||
long_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, model, speed, inference_geo, service_tier),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -262,6 +270,14 @@ db.exec(`
|
||||
web_search_requests INTEGER NOT NULL DEFAULT 0,
|
||||
web_fetch_requests INTEGER NOT NULL DEFAULT 0,
|
||||
code_execution_requests INTEGER NOT NULL DEFAULT 0,
|
||||
-- Subsets of the token columns spent by requests whose prompt was above
|
||||
-- LONG_CONTEXT_TOKENS (server/lib/pricing-constants.js), priced at the
|
||||
-- rule's long_* rates. Kept at their high-water mark like the hourly rows.
|
||||
long_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
long_cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, hour, model, speed, inference_geo, service_tier),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -279,6 +295,11 @@ db.exec(`
|
||||
-- caching multipliers (see server/lib/pricing-constants.js).
|
||||
fast_input_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
-- Rates for a request whose prompt is above LONG_CONTEXT_TOKENS (Haiku 5.5);
|
||||
-- 0 = no long-context pricing. Cache rates are derived from long_input by
|
||||
-- the same ratio, as fast mode's are.
|
||||
long_input_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
long_output_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
-- Time-limited introductory rates. When intro_until is set, usage on/before
|
||||
-- that date (YYYY-MM-DD) is priced at the intro_* rates and usage after it at
|
||||
-- the standard rates — so promo pricing (e.g. Claude Sonnet 5's launch
|
||||
@@ -999,6 +1020,19 @@ try {
|
||||
setFast.run(30, 150, "claude-opus-4-6%");
|
||||
}
|
||||
|
||||
// Migrate: long-context rate columns (0 = none; filled by the pricing sync or
|
||||
// DEFAULT_PRICING's top-up below).
|
||||
try {
|
||||
db.prepare("SELECT long_input_per_mtok FROM model_pricing LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare(
|
||||
"ALTER TABLE model_pricing ADD COLUMN long_input_per_mtok REAL NOT NULL DEFAULT 0"
|
||||
).run();
|
||||
db.prepare(
|
||||
"ALTER TABLE model_pricing ADD COLUMN long_output_per_mtok REAL NOT NULL DEFAULT 0"
|
||||
).run();
|
||||
}
|
||||
|
||||
// Migrate: add time-limited introductory-rate columns to model_pricing.
|
||||
// Usage on/before intro_until prices at the intro_* rates; usage after prices at
|
||||
// standard — so promo pricing (e.g. Claude Sonnet 5's launch discount) stays
|
||||
@@ -1602,6 +1636,27 @@ try {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
|
||||
// Migrate: long-context subsets (Haiku 5.5 bills a prompt above 100k tokens at
|
||||
// higher rates). Plain added columns, NOT a new key dimension: a build that
|
||||
// predates them (prod and dev share one DB) keeps writing its rows unchanged,
|
||||
// and an existing row's totals never split, so nothing is counted twice.
|
||||
const LONG_TOKEN_COLS = [
|
||||
"long_input_tokens",
|
||||
"long_output_tokens",
|
||||
"long_cache_read_tokens",
|
||||
"long_cache_write_tokens",
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
for (const table of ["token_usage", "token_usage_hourly"]) {
|
||||
try {
|
||||
db.prepare(`SELECT long_input_tokens FROM ${table} LIMIT 1`).get();
|
||||
} catch {
|
||||
for (const col of LONG_TOKEN_COLS) {
|
||||
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 0`).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Startup cleanup: mark stale active sessions as completed.
|
||||
// Legacy sessions (created before SessionEnd hook) will never receive a SessionEnd event,
|
||||
// so they stay "active" forever. Complete any active session whose last event is older than
|
||||
@@ -2052,6 +2107,38 @@ const stmts = {
|
||||
OR excluded.web_fetch_requests > web_fetch_requests
|
||||
OR excluded.code_execution_requests > code_execution_requests
|
||||
`),
|
||||
// A bucket's long-context subsets (see the token_usage columns), written
|
||||
// after its row by the same caller. Args: the five long_* values in column
|
||||
// order, then the row's key. RAISE keeps the high-water mark (the live and
|
||||
// import writers, like the hourly rows); SET is the exact reconcile path's.
|
||||
raiseLongTokens: db.prepare(`
|
||||
UPDATE token_usage SET
|
||||
long_input_tokens = MAX(long_input_tokens, ?),
|
||||
long_output_tokens = MAX(long_output_tokens, ?),
|
||||
long_cache_read_tokens = MAX(long_cache_read_tokens, ?),
|
||||
long_cache_write_tokens = MAX(long_cache_write_tokens, ?),
|
||||
long_cache_write_1h_tokens = MAX(long_cache_write_1h_tokens, ?)
|
||||
WHERE session_id = ? AND model = ? AND speed = ? AND inference_geo = ? AND service_tier = ?
|
||||
`),
|
||||
setLongTokens: db.prepare(`
|
||||
UPDATE token_usage SET
|
||||
long_input_tokens = ?,
|
||||
long_output_tokens = ?,
|
||||
long_cache_read_tokens = ?,
|
||||
long_cache_write_tokens = ?,
|
||||
long_cache_write_1h_tokens = ?
|
||||
WHERE session_id = ? AND model = ? AND speed = ? AND inference_geo = ? AND service_tier = ?
|
||||
`),
|
||||
raiseLongHourly: db.prepare(`
|
||||
UPDATE token_usage_hourly SET
|
||||
long_input_tokens = MAX(long_input_tokens, ?),
|
||||
long_output_tokens = MAX(long_output_tokens, ?),
|
||||
long_cache_read_tokens = MAX(long_cache_read_tokens, ?),
|
||||
long_cache_write_tokens = MAX(long_cache_write_tokens, ?),
|
||||
long_cache_write_1h_tokens = MAX(long_cache_write_1h_tokens, ?)
|
||||
WHERE session_id = ? AND hour = ? AND model = ? AND speed = ? AND inference_geo = ?
|
||||
AND service_tier = ?
|
||||
`),
|
||||
getTokenTotals: db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(input_tokens + baseline_input), 0) as total_input,
|
||||
@@ -2073,7 +2160,9 @@ const stmts = {
|
||||
cache_write_1h_tokens + baseline_cache_write_1h as cache_write_1h_tokens,
|
||||
web_search_requests + baseline_web_search as web_search_requests,
|
||||
web_fetch_requests + baseline_web_fetch as web_fetch_requests,
|
||||
code_execution_requests + baseline_code_execution as code_execution_requests
|
||||
code_execution_requests + baseline_code_execution as code_execution_requests,
|
||||
long_input_tokens, long_output_tokens, long_cache_read_tokens,
|
||||
long_cache_write_tokens, long_cache_write_1h_tokens
|
||||
FROM token_usage WHERE session_id = ?`
|
||||
),
|
||||
|
||||
|
||||
@@ -294,6 +294,8 @@ function startServer(app, port) {
|
||||
// server even when it bound a non-default port (the desktop app falls
|
||||
// back off 4820 when that port is already taken).
|
||||
writeServerInfo(port);
|
||||
// A server that starts ends a Quit: the bootstrap may start it again.
|
||||
require("./lib/quit").clearQuit();
|
||||
const sharedDbPeers = peersSharingDataDir();
|
||||
if (sharedDbPeers.length > 0) {
|
||||
const peerPorts = sharedDbPeers.map((p) => p.port).join(", ");
|
||||
@@ -1101,6 +1103,8 @@ if (require.main === module) {
|
||||
startServer(app, PORT).then((server) => {
|
||||
httpServer = server;
|
||||
startBackgroundServices();
|
||||
// The status-area icon (server/lib/tray.js) — production servers only.
|
||||
require("./lib/tray").startTrayWatch();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+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,
|
||||
};
|
||||
@@ -49,6 +49,35 @@ const err = (description) => ({
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||
});
|
||||
|
||||
// The Users page's account changes: every body carries the admin's own
|
||||
// password again (`currentPassword`).
|
||||
const userBody = {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { user: { $ref: "#/components/schemas/AuthUser" } } },
|
||||
},
|
||||
};
|
||||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
const changeBody = (properties, required = []) => ({
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: [...required, "currentPassword"],
|
||||
properties: { ...properties, currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const changeResponses = {
|
||||
200: { description: "Changed", content: userBody },
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD"),
|
||||
403: err("EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE"),
|
||||
404: err("ENOUSER / ENOLANE"),
|
||||
409: err("ESELF — your own role or disable"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/users": {
|
||||
get: {
|
||||
@@ -95,6 +124,70 @@ const paths = {
|
||||
403: err("EADMIN — a member"),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "createServerUser",
|
||||
summary:
|
||||
"Admin: add an account. Needs the admin's own password again (currentPassword); audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["username", "password", "currentPassword"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string", minLength: 8 },
|
||||
role: { type: "string", enum: ["admin", "analyst", "member"], default: "member" },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: "Created", content: userBody },
|
||||
400: err("EBADUSER / EBADPASSWORD / EBADROLE"),
|
||||
403: err("EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE"),
|
||||
409: err("EEXISTS"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}": {
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
operationId: "updateServerUser",
|
||||
summary:
|
||||
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
|
||||
parameters: [idParam],
|
||||
requestBody: changeBody({
|
||||
role: { type: "string", enum: ["admin", "analyst", "member"] },
|
||||
disabled: { type: "boolean" },
|
||||
}),
|
||||
responses: changeResponses,
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/password": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "resetServerUserPassword",
|
||||
summary: "Admin: a new password for that user; every login of theirs ends",
|
||||
parameters: [idParam],
|
||||
requestBody: changeBody({ password: { type: "string", minLength: 8 } }, ["password"]),
|
||||
responses: changeResponses,
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/workspaces": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "assignServerUserWorkspace",
|
||||
summary: "Admin: make that user the owner of a workspace (lane)",
|
||||
parameters: [idParam],
|
||||
requestBody: changeBody({ laneId: { type: "integer" } }, ["laneId"]),
|
||||
responses: changeResponses,
|
||||
},
|
||||
},
|
||||
"/api/users/audit": {
|
||||
get: {
|
||||
|
||||
@@ -595,6 +595,28 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/blame": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Annotate: each line's last commit",
|
||||
description:
|
||||
"`git blame --porcelain` of the working-tree file, for the Changes panel's Annotate gutter. Same lexical lane guard and secret refusal as /git/file, capped at 1 MB. A line nobody committed yet is null. An untracked file (no history) answers {available:false}. Read-only, no same-origin guard.",
|
||||
operationId: "blameLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, path, commits: {<sha>: {short, author, date, summary}}, lines: [<sha>|null]}.",
|
||||
},
|
||||
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
|
||||
403: { description: "ESECRET." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/raw": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
@@ -742,6 +764,7 @@ const paths = {
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"ignore",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -782,13 +805,29 @@ const paths = {
|
||||
paths: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "stage, unstage, discard; each must be a path git reports changed.",
|
||||
description:
|
||||
"stage, unstage, discard, ignore (untracked only, else 400 ETRACKED; appends an anchored line to the workspace's .gitignore); each must be a path git reports changed. commit and amend: commit exactly these files' working-tree content (`git commit --only`), an untracked one included, leaving every other change — staged or not — uncommitted; a conflicted one is 409 ECONFLICTS. amend with an empty list rewords only (a message is then required, else 400 ENOCHANGES).",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
|
||||
},
|
||||
hunks: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hunk: { type: "string" },
|
||||
blocks: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
description:
|
||||
"commit, amend (optional): the hunk-level commit — per tracked, non-renamed path, only these change blocks of these hunks (`@@` lines as the diff at `context` returned them). Built in a temporary index from git's own fresh diff and committed from it; the real index entries of the committed paths are then reset to the new HEAD, the rest of a partial file left as an unstaged change. A hunk no longer in the diff is 409 EHUNKGONE, an untracked path 400 EUNTRACKED, a bad shape 400 EBADPATH.",
|
||||
},
|
||||
hunk: {
|
||||
type: "string",
|
||||
description:
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* - PUT /api/settings/claude-home (Settings)
|
||||
* - GET /api/settings/autostart (Settings)
|
||||
* - PUT /api/settings/autostart (Settings)
|
||||
* - GET /api/settings/tray (Settings)
|
||||
* - PUT /api/settings/tray (Settings)
|
||||
* - GET /api/workflows/runs (Workflows)
|
||||
* - GET /api/workflows/runs/{runId} (Workflows)
|
||||
* - GET /api/remote-sources (Remote Sources)
|
||||
@@ -1064,6 +1066,60 @@ const paths = {
|
||||
};
|
||||
})(),
|
||||
|
||||
"/api/settings/tray": (() => {
|
||||
const status = {
|
||||
description: "Status-area icon state",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["available", "enabled", "running"],
|
||||
properties: {
|
||||
available: {
|
||||
type: "boolean",
|
||||
description: "False in server mode, or on Linux with no graphical session",
|
||||
},
|
||||
enabled: { type: "boolean", description: "On by default" },
|
||||
running: { type: "boolean", description: "The icon process is heartbeating" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
get: {
|
||||
tags: ["Settings"],
|
||||
summary: "Whether AgentHub's icon shows in the OS status area",
|
||||
operationId: "getTray",
|
||||
responses: { 200: status },
|
||||
},
|
||||
put: {
|
||||
tags: ["Settings"],
|
||||
summary: "Show or hide the status-area icon",
|
||||
description:
|
||||
"Clears or writes `<data dir>/runtime/tray-off` and starts or stops the icon process. Never touches the server. Same-origin only.",
|
||||
operationId: "setTray",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["enabled"],
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { ...status, description: "The new state" },
|
||||
400: { description: "EBADVALUE: `enabled` is not a boolean" },
|
||||
403: { description: "EBADORIGIN: another site asked" },
|
||||
},
|
||||
},
|
||||
};
|
||||
})(),
|
||||
|
||||
"/api/settings/codex-hooks": (() => {
|
||||
const status = {
|
||||
description: "Where AgentHub's Codex hooks stand",
|
||||
|
||||
@@ -904,6 +904,15 @@ function createOpenApiSpec() {
|
||||
type: "number",
|
||||
description: "Fast-mode output rate (0 = none)",
|
||||
},
|
||||
long_input_per_mtok: {
|
||||
type: "number",
|
||||
description:
|
||||
"Input rate for a request whose prompt is above 100k tokens (0 = none); cache rates scale by long_input / input",
|
||||
},
|
||||
long_output_per_mtok: {
|
||||
type: "number",
|
||||
description: "Output rate for a request whose prompt is above 100k tokens (0 = none)",
|
||||
},
|
||||
source: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
@@ -971,6 +980,15 @@ function createOpenApiSpec() {
|
||||
web_search_requests: { type: "integer" },
|
||||
web_fetch_requests: { type: "integer" },
|
||||
code_execution_requests: { type: "integer" },
|
||||
long_input_tokens: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Part of input_tokens spent by requests whose prompt was above 100k tokens",
|
||||
},
|
||||
long_output_tokens: { type: "integer" },
|
||||
long_cache_read_tokens: { type: "integer" },
|
||||
long_cache_write_tokens: { type: "integer" },
|
||||
long_cache_write_1h_tokens: { type: "integer" },
|
||||
cost: { type: "number" },
|
||||
matched_rule: { type: "string", nullable: true },
|
||||
},
|
||||
|
||||
@@ -170,7 +170,12 @@ router.get("/:id/usage", (req, res) => {
|
||||
SUM(tu.output_tokens + tu.baseline_output) AS output_tokens,
|
||||
SUM(tu.cache_read_tokens + tu.baseline_cache_read) AS cache_read_tokens,
|
||||
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens,
|
||||
SUM(tu.long_input_tokens) AS long_input_tokens,
|
||||
SUM(tu.long_output_tokens) AS long_output_tokens,
|
||||
SUM(tu.long_cache_read_tokens) AS long_cache_read_tokens,
|
||||
SUM(tu.long_cache_write_tokens) AS long_cache_write_tokens,
|
||||
SUM(tu.long_cache_write_1h_tokens) AS long_cache_write_1h_tokens
|
||||
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
|
||||
WHERE s.tool = ? AND ${own.where}
|
||||
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
|
||||
|
||||
+13
-1
@@ -18,6 +18,7 @@ function broadcast(type, data) {
|
||||
}
|
||||
const TranscriptCache = require("../lib/transcript-cache");
|
||||
const { writeHourly } = require("../lib/hourly-usage");
|
||||
const { longArgs } = require("../lib/token-usage");
|
||||
const { scanAndImportSubagents } = require("../../scripts/import-history");
|
||||
const { evaluateEvent } = require("../lib/alerts");
|
||||
const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
|
||||
@@ -1036,6 +1037,17 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
tokens.webFetch,
|
||||
tokens.codeExec
|
||||
);
|
||||
const long = longArgs(tokens);
|
||||
if (long) {
|
||||
stmts.raiseLongTokens.run(
|
||||
...long,
|
||||
sessionId,
|
||||
tokens.model,
|
||||
tokens.speed,
|
||||
tokens.geo,
|
||||
tokens.tier
|
||||
);
|
||||
}
|
||||
}
|
||||
writeHourly(sessionId, result.tokensByHour);
|
||||
}
|
||||
@@ -1602,7 +1614,7 @@ router.post("/event", (req, res) => {
|
||||
|
||||
// ── POST /api/hooks/ingest-batch ────────────────────────────────────────────
|
||||
// Hooks from a machine this dashboard cannot reach over SSH, pushed with the
|
||||
// bearer token Settings → Data → Push ingest makes (server/lib/push-token.js).
|
||||
// bearer token Settings → Database → Push ingest makes (server/lib/push-token.js).
|
||||
// Off — 503 — until a token exists. Body: {source, events: [{hook_type, data,
|
||||
// tool?}]}; each event goes through ingestHook exactly as POST /event would,
|
||||
// with its session tagged `push:<source>`. Under /hooks, so DASHBOARD_TOKEN
|
||||
|
||||
+39
-12
@@ -57,6 +57,7 @@ const {
|
||||
listTree,
|
||||
listTreeAll,
|
||||
readFileAt,
|
||||
blameFile,
|
||||
readImageAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
@@ -1010,6 +1011,29 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Annotate (`?path=`): who last changed each line of the working-tree file —
|
||||
* git-diff.js's `blameFile`, with the whole-file read's guards (lexical lane
|
||||
* guard, 403 ESECRET, a size cap). A read like `/git/file`, so unguarded too.
|
||||
*/
|
||||
router.get("/:id/git/blame", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
|
||||
res.json({ available: true, ...blame });
|
||||
} catch (err) {
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Not tracked (git blame has no history for it) or not a repo.
|
||||
res.json({ available: false });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
|
||||
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
|
||||
@@ -1145,9 +1169,10 @@ router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json({ message: await suggestCommitMessage(t.cwd) });
|
||||
const paths = Array.isArray(req.body?.paths) ? req.body.paths : undefined;
|
||||
res.json({ message: await suggestCommitMessage(t.cwd, { paths }) });
|
||||
} catch (err) {
|
||||
const status = ["ENOSTAGED", "ENOTOOL"].includes(err.code)
|
||||
const status = ["ENOSTAGED", "ENOTOOL", "EBADPATH"].includes(err.code)
|
||||
? 400
|
||||
: err.code === "EAI"
|
||||
? 502
|
||||
@@ -1200,6 +1225,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"EBADSHA",
|
||||
"EMERGECOMMIT",
|
||||
"ENOPARENT",
|
||||
"ETRACKED",
|
||||
];
|
||||
// A numeric code is git's own exit status (execFile reports one that way):
|
||||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||||
@@ -1209,16 +1235,17 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
// EHUNKGONE is the same kind of answer: the tree moved under the click.
|
||||
const status = badRequest.includes(err.code)
|
||||
? 400
|
||||
: exited || [
|
||||
"EHUNKGONE",
|
||||
"EGITAUTH",
|
||||
"EMERGING",
|
||||
"ENOMERGE",
|
||||
"ECONFLICTS",
|
||||
"EPUSHED",
|
||||
"EEMPTY",
|
||||
"ESTALE",
|
||||
].includes(err.code)
|
||||
: exited ||
|
||||
[
|
||||
"EHUNKGONE",
|
||||
"EGITAUTH",
|
||||
"EMERGING",
|
||||
"ENOMERGE",
|
||||
"ECONFLICTS",
|
||||
"EPUSHED",
|
||||
"EEMPTY",
|
||||
"ESTALE",
|
||||
].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
|
||||
@@ -83,6 +83,35 @@ function ratesForBucket(rule, row, asOf) {
|
||||
return { rIn, rOut, rRead, r5m, r1h };
|
||||
}
|
||||
|
||||
const LONG_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
].map((c) => `long_${c}`);
|
||||
|
||||
/**
|
||||
* What the long-context part of a bucket adds on top of pricing ALL of it at
|
||||
* the bucket's rates: each long subset (`long_<col>`, capped at its total) at
|
||||
* (factor - 1) x its rate. The input-side factor is long_input / input and
|
||||
* also scales the cache rates, as fast mode's does; output has its own. A rule
|
||||
* with no long rates adds nothing.
|
||||
*/
|
||||
function longContextExtra(rule, row, { rIn, rOut, rRead, r5m, r1h }) {
|
||||
const r = rule || {};
|
||||
const factor = (longRate, base) => (longRate > 0 && base > 0 ? longRate / base : 1);
|
||||
const fIn = factor(r.long_input_per_mtok, r.input_per_mtok);
|
||||
const fOut = factor(r.long_output_per_mtok, r.output_per_mtok);
|
||||
if (fIn === 1 && fOut === 1) return 0;
|
||||
const long = (col) => Math.min(row[`long_${col}`] || 0, row[col] || 0);
|
||||
const l1h = long("cache_write_1h_tokens");
|
||||
const l5m = Math.max(0, long("cache_write_tokens") - l1h);
|
||||
const inSide =
|
||||
long("input_tokens") * rIn + long("cache_read_tokens") * rRead + l5m * r5m + l1h * r1h;
|
||||
return ((fIn - 1) * inSide + (fOut - 1) * long("output_tokens") * rOut) / 1e6;
|
||||
}
|
||||
|
||||
// Calculate cost for a set of token buckets against pricing rules. Each bucket
|
||||
// is (model, speed, inference_geo, service_tier) with token counts plus the 1h
|
||||
// cache-write split and server-tool request counts. Cost = token cost (rate-
|
||||
@@ -128,7 +157,8 @@ function calculateCost(tokenRows, pricingRules, asOf) {
|
||||
unpriced.set(row.model, u);
|
||||
}
|
||||
|
||||
const { rIn, rOut, rRead, r5m, r1h } = ratesForBucket(rule, row, asOf);
|
||||
const rates = ratesForBucket(rule, row, asOf);
|
||||
const { rIn, rOut, rRead, r5m, r1h } = rates;
|
||||
const cw1h = row.cache_write_1h_tokens || 0;
|
||||
const cw5m = Math.max(0, (row.cache_write_tokens || 0) - cw1h);
|
||||
const tCost =
|
||||
@@ -136,7 +166,8 @@ function calculateCost(tokenRows, pricingRules, asOf) {
|
||||
(row.output_tokens / 1e6) * rOut +
|
||||
(row.cache_read_tokens / 1e6) * rRead +
|
||||
(cw5m / 1e6) * r5m +
|
||||
(cw1h / 1e6) * r1h;
|
||||
(cw1h / 1e6) * r1h +
|
||||
longContextExtra(rule, row, rates);
|
||||
|
||||
const wsCost = ((row.web_search_requests || 0) / 1000) * WEB_SEARCH_PER_1K_SEARCHES;
|
||||
const ceHours = estimateCodeExecHours(
|
||||
@@ -163,6 +194,7 @@ function calculateCost(tokenRows, pricingRules, asOf) {
|
||||
web_search_requests: 0,
|
||||
web_fetch_requests: 0,
|
||||
code_execution_requests: 0,
|
||||
...Object.fromEntries(LONG_COLS.map((c) => [c, 0])),
|
||||
_cost: 0,
|
||||
matched_rule: rule?.model_pattern || null,
|
||||
};
|
||||
@@ -174,6 +206,7 @@ function calculateCost(tokenRows, pricingRules, asOf) {
|
||||
agg.web_search_requests += row.web_search_requests || 0;
|
||||
agg.web_fetch_requests += row.web_fetch_requests || 0;
|
||||
agg.code_execution_requests += row.code_execution_requests || 0;
|
||||
for (const c of LONG_COLS) agg[c] += row[c] || 0;
|
||||
agg._cost += tCost + wsCost;
|
||||
breakdownMap.set(key, agg);
|
||||
}
|
||||
@@ -221,6 +254,7 @@ function calculateDailyCosts(dailyTokenRows, pricingRules) {
|
||||
web_search_requests: row.web_search_requests,
|
||||
web_fetch_requests: row.web_fetch_requests,
|
||||
code_execution_requests: row.code_execution_requests,
|
||||
...Object.fromEntries(LONG_COLS.map((c) => [c, row[c]])),
|
||||
});
|
||||
rowsByDate.set(row.date, rows);
|
||||
}
|
||||
@@ -239,6 +273,7 @@ const USAGE_COLS = [
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
...LONG_COLS,
|
||||
];
|
||||
const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
|
||||
|
||||
@@ -263,7 +298,8 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
tu.cache_write_1h_tokens + tu.baseline_cache_write_1h as cache_write_1h_tokens,
|
||||
tu.web_search_requests + tu.baseline_web_search as web_search_requests,
|
||||
tu.web_fetch_requests + tu.baseline_web_fetch as web_fetch_requests,
|
||||
tu.code_execution_requests + tu.baseline_code_execution as code_execution_requests
|
||||
tu.code_execution_requests + tu.baseline_code_execution as code_execution_requests,
|
||||
${LONG_COLS.map((c) => `tu.${c}`).join(", ")}
|
||||
FROM token_usage tu
|
||||
JOIN sessions s ON s.id = tu.session_id
|
||||
${whereClause}`
|
||||
@@ -582,7 +618,7 @@ router.get("/cost", (req, res) => {
|
||||
res.json(computeCost(scope, range, tzModifier));
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
// GET /api/pricing/quota - plan quota spent per AI tool (server/lib/quota.js lists them):
|
||||
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
|
||||
router.get("/quota", async (req, res) => {
|
||||
const out = await quotas({ force: req.query.force === "1" });
|
||||
|
||||
@@ -138,7 +138,9 @@ function tokensForSessions(ids) {
|
||||
cache_write_1h_tokens + baseline_cache_write_1h as cache_write_1h_tokens,
|
||||
web_search_requests + baseline_web_search as web_search_requests,
|
||||
web_fetch_requests + baseline_web_fetch as web_fetch_requests,
|
||||
code_execution_requests + baseline_code_execution as code_execution_requests
|
||||
code_execution_requests + baseline_code_execution as code_execution_requests,
|
||||
long_input_tokens, long_output_tokens, long_cache_read_tokens,
|
||||
long_cache_write_tokens, long_cache_write_1h_tokens
|
||||
FROM token_usage WHERE session_id IN (${ids.map(() => "?").join(",")})`
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
+30
-12
@@ -67,18 +67,9 @@ function getHookStatus() {
|
||||
}
|
||||
const raw = fs.readFileSync(getSettingsPath(), "utf8");
|
||||
const settings = JSON.parse(raw);
|
||||
const hookTypes = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
];
|
||||
// The enabled plugin runs the hooks from its own plugin.json, so
|
||||
// settings.json rightly holds none of ours (installHooks removes them).
|
||||
const { pluginProvidesHooks } = require("../../scripts/install-hooks");
|
||||
const { HOOK_TYPES: hookTypes, pluginProvidesHooks } = require("../../scripts/install-hooks");
|
||||
if (pluginProvidesHooks(settings)) {
|
||||
return {
|
||||
installed: true,
|
||||
@@ -200,10 +191,10 @@ function clearAll() {
|
||||
// not cascade and stay as orphans.
|
||||
db.prepare("DELETE FROM workflows").run();
|
||||
// session_snapshots survive: a snapshot is a frozen copy the user chose to
|
||||
// keep, like alert rules — deleted only from Settings → Snapshots.
|
||||
// keep, like alert rules — deleted only from Settings → Snapshots & reports.
|
||||
// report_definitions and report_runs survive too: a definition is user
|
||||
// configuration and a run's files are reports already delivered — removed
|
||||
// only from Settings → Reports.
|
||||
// only from Settings → Snapshots & reports.
|
||||
}
|
||||
|
||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||
@@ -283,6 +274,33 @@ router.put("/autostart", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/tray — AgentHub's icon in the OS status area
|
||||
// (server/lib/tray.js). On by default; a PUT starts or stops the icon process.
|
||||
const tray = require("../lib/tray");
|
||||
router.get("/tray", (_req, res) => res.json(tray.status()));
|
||||
router.put("/tray", sameOriginGuard, (req, res) => {
|
||||
if (typeof req.body?.enabled !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADVALUE", message: "`enabled` must be a boolean" } });
|
||||
}
|
||||
res.json(tray.setEnabled(req.body.enabled));
|
||||
});
|
||||
|
||||
// POST /api/settings/quit — Quit AgentHub (server/lib/quit.js): kill every
|
||||
// lane pane, then stop this server once the answer is out. It stays stopped
|
||||
// (the bootstrap starts none) until a server starts again — `agenthub start`,
|
||||
// or boot/login through an enabled autostart unit.
|
||||
const quit = require("../lib/quit");
|
||||
router.post("/quit", sameOriginGuard, (_req, res) => {
|
||||
quit.markQuit();
|
||||
const runs = require("../lib/pty-run");
|
||||
let killed = 0;
|
||||
for (const run of runs.listRuns()) if (runs.killRun(run.id)) killed++;
|
||||
res.on("finish", () => quit.stopServer());
|
||||
res.json({ ok: true, killed });
|
||||
});
|
||||
|
||||
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
|
||||
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
|
||||
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.
|
||||
|
||||
+118
-3
@@ -5,15 +5,23 @@
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent in the
|
||||
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
|
||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* (server/lib/audit.js). Read-only: accounts are managed from the host's shell.
|
||||
* (server/lib/audit.js).
|
||||
*
|
||||
* The Users page's account changes — `POST /` (create), `PATCH /:id` (role,
|
||||
* disabled), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
|
||||
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
||||
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
||||
* an admin never demotes or disables themselves (ESELF), every change is
|
||||
* audited, and in local mode — no login to check — they all refuse.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { listUsers } = require("../lib/auth");
|
||||
const auth = require("../lib/auth");
|
||||
const { listUsers } = auth;
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
const { listAudit } = require("../lib/audit");
|
||||
const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -26,6 +34,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",
|
||||
];
|
||||
|
||||
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
@@ -70,4 +83,106 @@ router.get("/audit", (req, res) => {
|
||||
res.json({ entries: listAudit(req.query.limit) });
|
||||
});
|
||||
|
||||
const STATUS = {
|
||||
EBADUSER: 400,
|
||||
EBADPASSWORD: 400,
|
||||
EBADROLE: 400,
|
||||
EBADFIELD: 400,
|
||||
EREAUTH: 403,
|
||||
ELOCALMODE: 403,
|
||||
ENOUSER: 404,
|
||||
ENOLANE: 404,
|
||||
EEXISTS: 409,
|
||||
ESELF: 409,
|
||||
ETOOMANY: 429,
|
||||
};
|
||||
|
||||
/**
|
||||
* One account change: server mode only, the admin's password confirmed, the
|
||||
* change made, then audited. `fn(target)` returns the audit text.
|
||||
*/
|
||||
function accountChange(handler) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
if (!auth.isServerMode() || !req.user) {
|
||||
throw Object.assign(new Error("accounts are managed in server mode only"), {
|
||||
code: "ELOCALMODE",
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
||||
res.status(out.status || 200).json({ user: out.user });
|
||||
} catch (err) {
|
||||
const status = STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The account `:id` names (404 ENOUSER otherwise). */
|
||||
function target(req) {
|
||||
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
||||
if (!u) throw Object.assign(new Error("no such user"), { code: "ENOUSER" });
|
||||
return u;
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
accountChange(async (_req, body) => {
|
||||
const role = body.role === undefined ? "member" : body.role;
|
||||
const user = await auth.createUser(body.username, body.password, role);
|
||||
return { status: 201, user, action: `create ${user.username} (${user.role})` };
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id",
|
||||
accountChange(async (req, body) => {
|
||||
const u = target(req);
|
||||
const self = u.id === req.user.id;
|
||||
const done = [];
|
||||
if (body.role !== undefined && body.role !== u.role) {
|
||||
if (self)
|
||||
throw Object.assign(new Error("you cannot change your own role"), { code: "ESELF" });
|
||||
auth.setRole(u.username, body.role);
|
||||
done.push(`role ${body.role}`);
|
||||
}
|
||||
if (body.disabled !== undefined) {
|
||||
if (typeof body.disabled !== "boolean") {
|
||||
throw Object.assign(new Error("disabled: true or false"), { code: "EBADFIELD" });
|
||||
}
|
||||
if (self && body.disabled) {
|
||||
throw Object.assign(new Error("you cannot disable yourself"), { code: "ESELF" });
|
||||
}
|
||||
auth.setDisabled(u.username, body.disabled);
|
||||
done.push(body.disabled ? "disable" : "enable");
|
||||
}
|
||||
return {
|
||||
user: auth.publicUser(target(req)),
|
||||
action: `${u.username}: ${done.join(", ") || "no change"}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
const u = target(req);
|
||||
await auth.setPassword(u.username, body.password);
|
||||
return { user: auth.publicUser(u), action: `${u.username}: password reset` };
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/workspaces",
|
||||
accountChange(async (req, body) => {
|
||||
const u = target(req);
|
||||
auth.assignWorkspace(body.laneId, u.username);
|
||||
return { user: auth.publicUser(u), action: `${u.username}: workspace ${Number(body.laneId)}` };
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user