chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per account (agenthub.workspace.groups); a workspace joins one from its menu, a drop on the header, a drag under a row, or Alt+Up/Down past a group's edge; headers drag to reorder; a folded header shows its most urgent state; New workspace in this group; fold/unfold all. Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the Changes panel shows a parent folder's repos only when Settings -> Git -> Changes panel turns it on (agenthub.changes.innerRepos, off by default). Cost: the hook writer adds a session's sub-agent transcripts to its tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the session's own model was never counted live (~8% of a week here); an imported session is re-raised on every pass, so a claude -p run first seen with an empty transcript no longer stays at $0 (~10%). A one-time raise-only catch-up (user_version 5) re-reads ~/.claude/projects. Also: omp install script may redirect to its exact install.redirect URL (#85, repo-links test exempts it), native paste in the terminal.
This commit is contained in:
@@ -98,6 +98,16 @@ before(async () => {
|
||||
"scripted.json",
|
||||
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
||||
);
|
||||
write(
|
||||
"redirected.json",
|
||||
tool("redirected", {
|
||||
install: {
|
||||
kind: "script",
|
||||
url: "https://example.com/install",
|
||||
redirect: "https://cdn.example.net/install.sh",
|
||||
},
|
||||
})
|
||||
);
|
||||
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
||||
write("plain.json", tool("plain"));
|
||||
exe(path.join(shellBin, "npm"));
|
||||
@@ -342,6 +352,27 @@ describe("jobs", () => {
|
||||
assert.match(done.log, /larger than 1 MB/);
|
||||
});
|
||||
|
||||
it("follows a redirect off its site only to the catalog's exact redirect URL", async () => {
|
||||
let landed = "https://cdn.example.net/install.sh?x=1";
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: landed,
|
||||
arrayBuffer: async () => Buffer.from("echo hi\n"),
|
||||
});
|
||||
await jobs.start("redirected", "install");
|
||||
let done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /redirected off example\.com/);
|
||||
assert.equal(spawned.length, 0);
|
||||
|
||||
landed = "https://cdn.example.net/install.sh";
|
||||
await jobs.start("redirected", "install");
|
||||
done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(spawned[0].argv[0], "bash");
|
||||
});
|
||||
|
||||
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
||||
// A slow login-shell read at the end: the job must not look finished before it.
|
||||
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
||||
|
||||
@@ -261,6 +261,20 @@ describe("install/update fields and trust tiers", () => {
|
||||
update: { kind: "script" },
|
||||
})
|
||||
);
|
||||
write(
|
||||
tb,
|
||||
"redir.json",
|
||||
base("redir", {
|
||||
install: { kind: "script", url: "https://example.com/i", redirect: "https://cdn.io/i.sh" },
|
||||
})
|
||||
);
|
||||
write(
|
||||
tb,
|
||||
"badredir.json",
|
||||
base("badredir", {
|
||||
install: { kind: "script", url: "https://example.com/i", redirect: "http://cdn.io/i" },
|
||||
})
|
||||
);
|
||||
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
|
||||
write(
|
||||
tb,
|
||||
@@ -302,7 +316,8 @@ describe("install/update fields and trust tiers", () => {
|
||||
url: "https://example.com/install",
|
||||
});
|
||||
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
|
||||
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
|
||||
assert.equal(aiTools.get("redir").install.redirect, "https://cdn.io/i.sh");
|
||||
for (const id of ["badpkg", "httpurl", "offhost", "badredir", "npmnoinst", "selfbad"]) {
|
||||
assert.equal(aiTools.has(id), false, id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2092,6 +2092,95 @@ describe("an extra folder's own repo (?source=)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("repos inside a parent workspace folder (?repo=)", () => {
|
||||
const { execFileSync } = require("child_process");
|
||||
const gitEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
|
||||
);
|
||||
const gitIn = (dir, ...args) =>
|
||||
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
|
||||
encoding: "utf8",
|
||||
env: gitEnv,
|
||||
});
|
||||
let parent, laneId, otherLaneId;
|
||||
|
||||
before(async () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-nested-")));
|
||||
parent = path.join(root, "shop");
|
||||
for (const rel of ["api", "web", "group/admin"]) {
|
||||
const dir = path.join(parent, rel);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
gitIn(dir, "init", "-q", "-b", "main");
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
|
||||
gitIn(dir, "add", ".");
|
||||
gitIn(dir, "commit", "-qm", "init");
|
||||
}
|
||||
// Not repos: a plain folder, a dot folder and node_modules holding one,
|
||||
// and a symlink to a repo (never followed).
|
||||
fs.mkdirSync(path.join(parent, "docs"));
|
||||
fs.mkdirSync(path.join(parent, ".cache/x/.git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(parent, "node_modules/pkg/.git"), { recursive: true });
|
||||
fs.symlinkSync(path.join(parent, "api"), path.join(parent, "api-link"));
|
||||
// A repo found inside a repo is not walked into.
|
||||
fs.mkdirSync(path.join(parent, "web/inner/.git"), { recursive: true });
|
||||
laneId = (await request("POST", "/api/lanes", { cwd: parent })).body.lane.id;
|
||||
otherLaneId = (await request("POST", "/api/lanes", { cwd: path.join(root, "other") })).body.lane
|
||||
.id;
|
||||
});
|
||||
|
||||
it("lists the repos below the workspace folder", async () => {
|
||||
const r = await request("GET", `/api/lanes/${laneId}/repos`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.root, false);
|
||||
assert.deepEqual(
|
||||
r.body.items.map((i) => i.path),
|
||||
["api", "group/admin", "web"]
|
||||
);
|
||||
});
|
||||
|
||||
it("reads and writes git in the named repo only", async () => {
|
||||
fs.writeFileSync(path.join(parent, "group/admin/new.txt"), "x\n");
|
||||
const changes = await request("GET", `/api/lanes/${laneId}/git/changes?repo=group/admin`);
|
||||
assert.ok(changes.body.files.some((f) => f.path === "new.txt"));
|
||||
const facts = await request("GET", `/api/lanes/${laneId}/git?repo=api`);
|
||||
assert.equal(facts.body.available, true);
|
||||
assert.equal(facts.body.base, "main");
|
||||
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?repo=group/admin`, {
|
||||
paths: ["new.txt"],
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.match(
|
||||
gitIn(path.join(parent, "group/admin"), "diff", "--cached", "--name-only"),
|
||||
/new\.txt/
|
||||
);
|
||||
});
|
||||
|
||||
it("answers ENOREPO for any path the walk did not find", async () => {
|
||||
for (const rel of ["docs", "..", "../other", "/etc", "api-link", ".cache/x", "web/inner", ""]) {
|
||||
const r = await request(
|
||||
"GET",
|
||||
`/api/lanes/${laneId}/git/changes?repo=${encodeURIComponent(rel)}`
|
||||
);
|
||||
assert.equal(r.status, 404, rel);
|
||||
assert.equal(r.body.error.code, "ENOREPO", rel);
|
||||
}
|
||||
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?repo=api`);
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error.code, "ENOREPO");
|
||||
});
|
||||
|
||||
it("finds a repo cloned since the last read", async () => {
|
||||
require("../lib/nested-repos").clearNestedRepoCache();
|
||||
await request("GET", `/api/lanes/${laneId}/repos`);
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
const dir = path.join(parent, "late");
|
||||
fs.mkdirSync(dir);
|
||||
gitIn(dir, "init", "-q", "-b", "main");
|
||||
const r = await request("GET", `/api/lanes/${laneId}/git?repo=late`);
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// The server, not a stale client list, picks the tab and refuses a second pane
|
||||
// on a session one already holds.
|
||||
describe("start: slot auto and a session already live", () => {
|
||||
|
||||
@@ -74,6 +74,24 @@ describe("local mode: one account", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the sidebar's workspace groups, up to 64 KB", async () => {
|
||||
const groups = JSON.stringify({ groups: [{ id: "g1", name: "Web" }], of: { 1: "g1" } });
|
||||
const pad = "x".repeat(20 * 1024);
|
||||
let r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.workspace.groups": groups + pad } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.prefs["agenthub.workspace.groups"], groups + pad);
|
||||
r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.workspace.groups": "x".repeat(65 * 1024) } },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.workspace.groups": null } },
|
||||
});
|
||||
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
||||
});
|
||||
|
||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||
let r = await call("GET", "/api/me/instructions");
|
||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||
|
||||
@@ -81,9 +81,13 @@ const RULES = [
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
// A third-party project that really is on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads.
|
||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
||||
// Third-party projects that really are on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads, and the install script omp.sh
|
||||
// redirects to (server/data/ai-tools/omp.json `install.redirect`).
|
||||
skip: [
|
||||
/raw\.githubusercontent\.com\/BerriAI\/litellm$/,
|
||||
/raw\.githubusercontent\.com\/can1357\/oh-my-pi$/,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @file Tests for counting every token a Claude session spent: the hook
|
||||
* writer adds the session's sub-agent transcripts (`lib/subagent-usage.js`)
|
||||
* to its main one — total and per hour, a sub-agent on the session's own
|
||||
* model included, and a sub-agent that grew alone is picked up — and the
|
||||
* importer raises a session it first saw with an empty transcript once the
|
||||
* transcript has grown (`importSession`).
|
||||
* @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("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const http = require("http");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-subusage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const dbModule = require("../db");
|
||||
const { db } = dbModule;
|
||||
const importHistory = require("../../scripts/import-history");
|
||||
const { subagentUsage } = require("../lib/subagent-usage");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function post(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL("/api/hooks/event", BASE);
|
||||
const payload = JSON.stringify(body);
|
||||
const r = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", () => resolve(res.statusCode));
|
||||
}
|
||||
);
|
||||
r.on("error", reject);
|
||||
r.write(payload);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
|
||||
const line = (o) => JSON.stringify(o) + "\n";
|
||||
const assistant = (id, inTok, ts, model = "claude-sonnet-5") => ({
|
||||
type: "assistant",
|
||||
timestamp: ts,
|
||||
message: {
|
||||
id,
|
||||
model,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
usage: { input_tokens: inTok, output_tokens: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const total = (sid) =>
|
||||
db
|
||||
.prepare(
|
||||
"SELECT model, SUM(input_tokens + baseline_input) AS input FROM token_usage WHERE session_id = ? GROUP BY model ORDER BY model"
|
||||
)
|
||||
.all(sid)
|
||||
.map((r) => ({ ...r }));
|
||||
const hours = (sid) =>
|
||||
db
|
||||
.prepare(
|
||||
"SELECT hour, model, SUM(input_tokens) AS input FROM token_usage_hourly WHERE session_id = ? GROUP BY hour, model ORDER BY hour, model"
|
||||
)
|
||||
.all(sid)
|
||||
.map((r) => ({ ...r }));
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("sub-agent usage", () => {
|
||||
it("counts a session's sub-agents, own model included, in its total and its hours", async () => {
|
||||
const sid = "cdcdcdcd-1111-4111-8111-cdcdcdcdcdcd";
|
||||
const cwd = path.join(TMP, "work");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const tpath = path.join(TMP, `${sid}.jsonl`);
|
||||
fs.writeFileSync(tpath, line(assistant("m1", 100, "2026-10-22T01:00:00.000Z")));
|
||||
const subDir = path.join(TMP, sid, "subagents");
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const sub1 = path.join(subDir, "agent-a1.jsonl");
|
||||
fs.writeFileSync(sub1, line(assistant("s1", 40, "2026-10-22T02:00:00.000Z")));
|
||||
fs.writeFileSync(
|
||||
path.join(subDir, "agent-a2.jsonl"),
|
||||
line(assistant("s2", 7, "2026-10-22T02:30:00.000Z", "claude-haiku-5-5"))
|
||||
);
|
||||
// A Workflow agent is booked by workflow-ingest under its own tier: not here.
|
||||
fs.mkdirSync(path.join(subDir, "workflows", "run1"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(subDir, "workflows", "run1", "agent-w.jsonl"),
|
||||
line(assistant("w1", 1000, "2026-10-22T02:00:00.000Z"))
|
||||
);
|
||||
const data = { session_id: sid, cwd, transcript_path: tpath };
|
||||
|
||||
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
|
||||
assert.deepEqual(total(sid), [
|
||||
{ model: "claude-haiku-5-5", input: 7 },
|
||||
{ model: "claude-sonnet-5", input: 140 },
|
||||
]);
|
||||
assert.deepEqual(hours(sid), [
|
||||
{ hour: "2026-10-22T01", model: "claude-sonnet-5", input: 100 },
|
||||
{ hour: "2026-10-22T02", model: "claude-haiku-5-5", input: 7 },
|
||||
{ hour: "2026-10-22T02", model: "claude-sonnet-5", input: 40 },
|
||||
]);
|
||||
|
||||
// Only the sub-agent grew: the next hook still counts it, once.
|
||||
fs.appendFileSync(sub1, line(assistant("s3", 10, "2026-10-22T03:00:00.000Z")));
|
||||
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
|
||||
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
|
||||
assert.deepEqual(total(sid), [
|
||||
{ model: "claude-haiku-5-5", input: 7 },
|
||||
{ model: "claude-sonnet-5", input: 150 },
|
||||
]);
|
||||
assert.equal(hours(sid).find((h) => h.hour === "2026-10-22T03").input, 10);
|
||||
|
||||
// The importer's full re-read agrees and changes nothing (high-water).
|
||||
await importHistory.reconcileTokens(dbModule, { exact: false });
|
||||
assert.deepEqual(total(sid), [
|
||||
{ model: "claude-haiku-5-5", input: 7 },
|
||||
{ model: "claude-sonnet-5", input: 150 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is null for a session with no sub-agent transcript", () => {
|
||||
assert.equal(subagentUsage(path.join(TMP, "nope.jsonl")), null);
|
||||
assert.equal(subagentUsage(null), null);
|
||||
});
|
||||
|
||||
it("raises an imported session first seen before its transcript had any usage", async () => {
|
||||
const sid = "efefefef-2222-4222-8222-efefefefefef";
|
||||
const proj = path.join(TMP, "projects", "-tmp-eval");
|
||||
fs.mkdirSync(proj, { recursive: true });
|
||||
const tpath = path.join(proj, `${sid}.jsonl`);
|
||||
const user = {
|
||||
type: "user",
|
||||
sessionId: sid,
|
||||
cwd: "/tmp/eval",
|
||||
timestamp: "2026-10-23T03:00:00.000Z",
|
||||
message: { role: "user", content: "fix the bug" },
|
||||
};
|
||||
fs.writeFileSync(tpath, line(user));
|
||||
const parse = async () => {
|
||||
const s = await importHistory.parseSessionFile(tpath);
|
||||
s.parsedSubagents = [];
|
||||
return s;
|
||||
};
|
||||
importHistory.importSession(dbModule, await parse());
|
||||
assert.deepEqual(total(sid), []);
|
||||
|
||||
fs.appendFileSync(
|
||||
tpath,
|
||||
line({ ...assistant("e1", 500, "2026-10-23T03:01:00.000Z"), sessionId: sid })
|
||||
);
|
||||
const again = importHistory.importSession(dbModule, await parse());
|
||||
assert.equal(again.backfilled, true);
|
||||
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
||||
// Nothing new: no backfill reported, no change.
|
||||
assert.equal(importHistory.importSession(dbModule, await parse()).backfilled, undefined);
|
||||
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user