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:
2026-10-23 16:41:45 +07:00
parent 65a001dad4
commit c8bc4a95a9
54 changed files with 2521 additions and 112 deletions
+31
View File
@@ -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)));
+16 -1
View File
@@ -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);
}
});
+89
View File
@@ -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", () => {
+18
View File
@@ -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"] });
+7 -3
View File
@@ -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",
+184
View File
@@ -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 }]);
});
});