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 }]);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@
|
||||
"faviconDomain": "omp.sh",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://omp.sh/install"
|
||||
"url": "https://omp.sh/install",
|
||||
"redirect": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh"
|
||||
},
|
||||
"update": {
|
||||
"kind": "script"
|
||||
|
||||
@@ -690,6 +690,31 @@ function startBackgroundServices() {
|
||||
}
|
||||
}
|
||||
|
||||
// One-time (user_version 5) raise of every session's totals to its whole
|
||||
// transcript plus its sub-agents' (raise only: `exact: false`). Before
|
||||
// v2.8.23 a live session never counted a sub-agent on its own model, and a
|
||||
// session imported the moment its file appeared (a `claude -p` run with no
|
||||
// hooks) kept the usage of that moment, often none — both now counted as
|
||||
// they happen (server/lib/subagent-usage.js, importSession). Waits for
|
||||
// marker 4; written only on success, so a crash retries on the next start.
|
||||
{
|
||||
const dbModule = require("./db");
|
||||
if (dbModule.db.pragma("user_version", { simple: true }) === 4) {
|
||||
const t = setTimeout(() => {
|
||||
require("../scripts/import-history")
|
||||
.reconcileTokens(dbModule, { exact: false })
|
||||
.then((c) => {
|
||||
console.log(
|
||||
`Token catch-up: re-read ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
|
||||
);
|
||||
dbModule.db.pragma("user_version = 5");
|
||||
})
|
||||
.catch((err) => console.warn("token catch-up failed:", err?.message || err));
|
||||
}, 15_000);
|
||||
if (t.unref) t.unref();
|
||||
}
|
||||
}
|
||||
|
||||
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
|
||||
// daily cost split covers them too. Picks only sessions with no hourly rows,
|
||||
// so a later start re-reads nothing already done; non-blocking.
|
||||
|
||||
@@ -63,12 +63,14 @@ async function stepsFor(id, action) {
|
||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||
return tool.install.kind === "npm"
|
||||
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
|
||||
: [{ script: tool.install.url }];
|
||||
: [{ script: tool.install.url, redirect: tool.install.redirect }];
|
||||
}
|
||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
||||
if (tool.update.kind === "script") {
|
||||
return [{ script: tool.install.url, redirect: tool.install.redirect }];
|
||||
}
|
||||
const by = await versions.managedBy(id);
|
||||
if (!by) {
|
||||
throw makeErr(
|
||||
@@ -85,12 +87,16 @@ function append(j, text) {
|
||||
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
|
||||
}
|
||||
|
||||
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
|
||||
async function download(url) {
|
||||
/**
|
||||
* Download a vendor script to a 0600 temp file; the final URL must stay on its
|
||||
* site, or be exactly the catalog's `redirect`.
|
||||
*/
|
||||
async function download(url, redirect) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
|
||||
const final = new URL(res.url || url);
|
||||
const site = aiTools.siteOf(new URL(url).hostname);
|
||||
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
|
||||
const onSite = final.protocol === "https:" && aiTools.siteOf(final.hostname) === site;
|
||||
if (!onSite && final.href !== redirect) {
|
||||
throw new Error(`script redirected off ${site}: ${final.href}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
|
||||
@@ -169,7 +175,7 @@ async function runSteps(j, steps, onStepOk) {
|
||||
let tmp = null;
|
||||
if (step.script) {
|
||||
append(j, `$ download ${step.script}\n`);
|
||||
tmp = await download(step.script);
|
||||
tmp = await download(step.script, step.redirect);
|
||||
argv = ["bash", tmp];
|
||||
}
|
||||
try {
|
||||
|
||||
+17
-4
@@ -44,18 +44,24 @@ const isStrArray = (v, re) =>
|
||||
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
||||
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
||||
|
||||
/** The script URL, when it is https on the homepage's site; else null. */
|
||||
function scriptUrl(url, homepageUrl) {
|
||||
/** A plain https URL (no userinfo, no port), normalised; else null. */
|
||||
function httpsUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const home = new URL(homepageUrl);
|
||||
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
||||
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
||||
return u;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The script URL, when it is https on the homepage's site; else null. */
|
||||
function scriptUrl(url, homepageUrl) {
|
||||
const u = httpsUrl(url);
|
||||
const home = httpsUrl(homepageUrl);
|
||||
return u && home && siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
||||
}
|
||||
|
||||
/** `{install, update}` normalised, or null when either is malformed. */
|
||||
function installFields(doc) {
|
||||
let install = null;
|
||||
@@ -67,6 +73,13 @@ function installFields(doc) {
|
||||
const url = scriptUrl(i.url, doc.homepageUrl);
|
||||
if (!url) return null;
|
||||
install = { kind: "script", url };
|
||||
// The ONE exact URL the download may redirect to off the homepage's site
|
||||
// (omp.sh/install → raw.githubusercontent.com); never a site wildcard.
|
||||
if (i.redirect !== undefined) {
|
||||
const to = typeof i.redirect === "string" && httpsUrl(i.redirect);
|
||||
if (!to) return null;
|
||||
install.redirect = to.href;
|
||||
}
|
||||
} else return null;
|
||||
}
|
||||
let update = null;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file nested-repos.js
|
||||
* @description The git repositories INSIDE a workspace folder — a parent
|
||||
* folder holding several repos side by side (`~/work/shop/{api,web,admin}`),
|
||||
* each with its own `.git` — so the Changes panel can show one section per
|
||||
* repo, like VS Code's Source Control. Found by reading the disk only (no git
|
||||
* call): a directory holding a `.git` entry (a directory, or the file a
|
||||
* submodule or linked worktree has) is a repo, and the walk does not go
|
||||
* inside one. At most `MAX_DEPTH` levels below the workspace folder, never
|
||||
* through a symlink, never into a dot directory or `node_modules`/`vendor`,
|
||||
* at most `MAX_REPOS` repos and `MAX_ENTRIES` directory entries read, so a
|
||||
* folder with a huge tree (or a member flooding theirs) costs a bounded read.
|
||||
* Cached per folder for `TTL_MS`; `refresh` reads again.
|
||||
*
|
||||
* `repoFolder` is the git routes' `?repo=<path>`: the request names a path
|
||||
* RELATIVE to the workspace folder, and it is accepted only when it is one of
|
||||
* the repos this walk found — never a path the walk did not produce, so the
|
||||
* request can no more point the git routes at an arbitrary directory than
|
||||
* `?source=` can.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MAX_DEPTH = 2;
|
||||
const MAX_REPOS = 50;
|
||||
const MAX_ENTRIES = 5000;
|
||||
const TTL_MS = 30_000;
|
||||
const REREAD_MS = 2_000;
|
||||
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "__pycache__"]);
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function fail(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
async function hasGit(dir) {
|
||||
try {
|
||||
await fs.promises.lstat(path.join(dir, ".git"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `dir` or a folder above it holds a `.git` — the folder is itself in a repo. */
|
||||
async function insideRepo(dir) {
|
||||
for (let d = dir; ;) {
|
||||
if (await hasGit(d)) return true;
|
||||
const up = path.dirname(d);
|
||||
if (up === d) return false;
|
||||
d = up;
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(root) {
|
||||
const repos = [];
|
||||
let budget = MAX_ENTRIES;
|
||||
let truncated = false;
|
||||
const visit = async (dir, depth) => {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.promises.opendir(dir);
|
||||
} catch {
|
||||
return; // unreadable: not ours to report
|
||||
}
|
||||
const children = [];
|
||||
try {
|
||||
for await (const ent of handle) {
|
||||
if (--budget < 0) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
// `isDirectory()` is false for a symlink: a link is never followed.
|
||||
if (!ent.isDirectory() || ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
|
||||
children.push(ent.name);
|
||||
}
|
||||
} catch {
|
||||
/* a directory that vanished mid-read */
|
||||
}
|
||||
children.sort((a, b) => a.localeCompare(b));
|
||||
for (const name of children) {
|
||||
if (repos.length >= MAX_REPOS) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const abs = path.join(dir, name);
|
||||
if (await hasGit(abs)) {
|
||||
const rel = path.relative(root, abs).split(path.sep).join("/");
|
||||
repos.push({ path: rel, name: rel });
|
||||
} else if (depth < MAX_DEPTH) {
|
||||
await visit(abs, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root, 1);
|
||||
return { items: repos, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* The repos below `cwd`, plus whether `cwd` itself is in a repo (`root`).
|
||||
* @param {string} cwd The workspace folder (absolute).
|
||||
* @param {{refresh?: boolean}} [opts]
|
||||
* @returns {Promise<{root: boolean, items: {path: string, name: string}[], truncated: boolean}>}
|
||||
*/
|
||||
async function nestedRepos(cwd, { refresh = false } = {}) {
|
||||
const hit = cache.get(cwd);
|
||||
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.value;
|
||||
const pending = (async () => {
|
||||
const [root, found] = await Promise.all([insideRepo(cwd), walk(cwd)]);
|
||||
return { root, ...found };
|
||||
})();
|
||||
cache.set(cwd, { at: Date.now(), value: pending });
|
||||
try {
|
||||
return await pending;
|
||||
} catch (err) {
|
||||
cache.delete(cwd);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory `?repo=<rel>` names: only a repo `nestedRepos` found under
|
||||
* the workspace folder (re-read if the cached list does not have it — a repo
|
||||
* cloned in the last 30 s — but at most once per `REREAD_MS`, so a stream of
|
||||
* bad names cannot keep the disk walking).
|
||||
* @throws ENOREPO otherwise.
|
||||
*/
|
||||
async function repoFolder(cwd, rel) {
|
||||
const want = String(rel);
|
||||
const find = (list) => list.items.find((r) => r.path === want);
|
||||
let hit = find(await nestedRepos(cwd));
|
||||
if (!hit && Date.now() - (cache.get(cwd)?.at ?? 0) >= REREAD_MS) {
|
||||
hit = find(await nestedRepos(cwd, { refresh: true }));
|
||||
}
|
||||
if (!hit) throw fail("ENOREPO", "that folder is not a repository inside this workspace");
|
||||
return path.join(cwd, ...hit.path.split("/"));
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
function clearNestedRepoCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { nestedRepos, repoFolder, clearNestedRepoCache, MAX_DEPTH, MAX_REPOS };
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file subagent-usage — the token usage of a Claude session's sub-agents (the
|
||||
* `agent-*.jsonl` transcripts Claude Code writes beside the session's own, in
|
||||
* `<dir>/<session id>/subagents/` or `<dir>/subagents/<session id>/` — the
|
||||
* same places `findSessionSubagents` in scripts/import-history.js looks),
|
||||
* summed per pricing bucket and per UTC hour. The hook writer adds it to the
|
||||
* main transcript's usage, so a session's total and its hours count every
|
||||
* sub-agent, the same total the importer's `combineSessionTokens` writes
|
||||
* (both go through the high-water `replaceTokenUsage`, so neither can inflate
|
||||
* the other). Workflow-tool agents (`subagents/workflows/…`) are left out:
|
||||
* `workflow-ingest.js` books those under their own service tier. Parsing goes
|
||||
* through a TranscriptCache of its own (stat-cached, incremental), so the main
|
||||
* transcripts' cache is never crowded out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const TranscriptCache = require("./transcript-cache");
|
||||
const { emptyBucket, accumulateBucket } = require("./token-usage");
|
||||
|
||||
const cache = new TranscriptCache(500);
|
||||
|
||||
/** The session's sub-agent transcripts, flat ones only. */
|
||||
function subagentFiles(transcriptPath) {
|
||||
const dir = path.dirname(transcriptPath);
|
||||
const sessionId = path.basename(transcriptPath, ".jsonl");
|
||||
const out = [];
|
||||
for (const d of [
|
||||
path.join(dir, sessionId, "subagents"),
|
||||
path.join(dir, "subagents", sessionId),
|
||||
]) {
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(d);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const n of names) if (n.endsWith(".jsonl")) out.push(path.join(d, n));
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** `a` plus `b`, bucket by bucket (an hour bucket keeps its `hour`); never mutates. */
|
||||
function addTokens(a, b) {
|
||||
const out = {};
|
||||
for (const src of [a, b]) {
|
||||
for (const [key, t] of Object.entries(src || {})) {
|
||||
if (!out[key]) {
|
||||
out[key] = emptyBucket(t.model, t.speed, t.geo, t.tier);
|
||||
if (t.hour) out[key].hour = t.hour;
|
||||
}
|
||||
accumulateBucket(out[key], t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-agents' usage of the session whose transcript is `transcriptPath`:
|
||||
* `{tokensByModel, tokensByHour, parts}` (`parts` = each file's cached parse
|
||||
* result, the same object while that file is unchanged), or null when it has
|
||||
* no sub-agent with usage.
|
||||
*/
|
||||
function subagentUsage(transcriptPath) {
|
||||
if (typeof transcriptPath !== "string" || !transcriptPath.endsWith(".jsonl")) return null;
|
||||
const parts = [];
|
||||
for (const f of subagentFiles(transcriptPath)) {
|
||||
let r = null;
|
||||
try {
|
||||
r = cache.extract(f);
|
||||
} catch {
|
||||
/* unreadable or vanished: skipped this time */
|
||||
}
|
||||
if (r && r.tokensByModel) parts.push(r);
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
let tokensByModel = {};
|
||||
let tokensByHour = {};
|
||||
for (const r of parts) {
|
||||
tokensByModel = addTokens(tokensByModel, r.tokensByModel);
|
||||
tokensByHour = addTokens(tokensByHour, r.tokensByHour);
|
||||
}
|
||||
return { tokensByModel, tokensByHour, parts };
|
||||
}
|
||||
|
||||
/** Whether two `parts` lists are the same parses (nothing grew in between). */
|
||||
function sameParts(a, b) {
|
||||
const x = a || [];
|
||||
const y = b || [];
|
||||
return x.length === y.length && x.every((r, i) => r === y[i]);
|
||||
}
|
||||
|
||||
module.exports = { subagentUsage, subagentFiles, addTokens, sameParts };
|
||||
@@ -532,6 +532,39 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/repos": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The git repositories inside a workspace folder",
|
||||
description:
|
||||
"For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.",
|
||||
operationId: "getLaneRepos",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "refresh", in: "query", required: false, schema: { type: "string", enum: ["1"] } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "{root, items: [{path, name}], truncated}.",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
root: { type: "boolean" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" }, name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
truncated: { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -801,6 +801,7 @@ const paths = {
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file's bytes, as an attachment." },
|
||||
@@ -832,6 +833,7 @@ const paths = {
|
||||
},
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -874,6 +876,7 @@ const paths = {
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
+15
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
|
||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts (the session's sub-agent transcripts included, server/lib/subagent-usage.js), detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ function broadcast(type, data) {
|
||||
}
|
||||
const TranscriptCache = require("../lib/transcript-cache");
|
||||
const { writeHourly } = require("../lib/hourly-usage");
|
||||
const { subagentUsage, addTokens, sameParts } = require("../lib/subagent-usage");
|
||||
const { longArgs } = require("../lib/token-usage");
|
||||
const { scanAndImportSubagents } = require("../../scripts/import-history");
|
||||
const { evaluateEvent } = require("../lib/alerts");
|
||||
@@ -987,7 +988,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
// Already handled (see transcriptMarks): the same result skips the
|
||||
// idempotent writes below, a grown one handles only its new entries.
|
||||
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
|
||||
const unchanged = !!mark && mark.result === result;
|
||||
// The session's sub-agents spend too: their transcripts' usage joins the
|
||||
// main one's (`lib/subagent-usage.js`), and a sub-agent that grew is a
|
||||
// change even when the main transcript is not.
|
||||
const subs = subagentUsage(transcriptPath);
|
||||
const unchanged = !!mark && mark.result === result && sameParts(mark.subParts, subs?.parts);
|
||||
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
|
||||
|
||||
// Keep session.model in sync with the user's *current* model — the
|
||||
@@ -1074,10 +1079,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
}
|
||||
}
|
||||
|
||||
if (tokensByModel && !unchanged) {
|
||||
if ((tokensByModel || subs) && !unchanged) {
|
||||
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
|
||||
// the 1h cache-write split and server-tool request counts.
|
||||
for (const tokens of Object.values(tokensByModel)) {
|
||||
const allTokens = subs ? addTokens(tokensByModel, subs.tokensByModel) : tokensByModel;
|
||||
for (const tokens of Object.values(allTokens)) {
|
||||
stmts.replaceTokenUsage.run(
|
||||
sessionId,
|
||||
tokens.model,
|
||||
@@ -1105,7 +1111,10 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
);
|
||||
}
|
||||
}
|
||||
writeHourly(sessionId, result.tokensByHour);
|
||||
writeHourly(
|
||||
sessionId,
|
||||
subs ? addTokens(result.tokensByHour, subs.tokensByHour) : result.tokensByHour
|
||||
);
|
||||
}
|
||||
|
||||
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
|
||||
@@ -1229,6 +1238,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
|
||||
queueTranscriptMark(sessionId, transcriptPath, {
|
||||
result,
|
||||
subParts: subs?.parts ?? null,
|
||||
startedAt: session.started_at,
|
||||
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
|
||||
lastError: lastOf(result.errors),
|
||||
|
||||
+33
-4
@@ -17,6 +17,7 @@ const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
|
||||
const { sourceFolder } = require("../lib/lane-sources");
|
||||
const { nestedRepos, repoFolder } = require("../lib/nested-repos");
|
||||
const { suggestCommitMessage } = require("../lib/commit-message");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const {
|
||||
@@ -1315,6 +1316,23 @@ router.get("/:id/integrations/:name", (req, res) => {
|
||||
* POST and Express matches on method as well as path. Verified by moving this
|
||||
* registration after it: the suite stayed green.
|
||||
*/
|
||||
/**
|
||||
* The git repositories inside the workspace folder (`server/lib/nested-repos.js`):
|
||||
* a parent folder of several repos gets one Changes-panel section per repo,
|
||||
* each read and written through `?repo=<path>`. `root` says whether the
|
||||
* workspace folder is itself in a repo. Disk reads only, cached 30 s;
|
||||
* `?refresh=1` reads again. A missing folder is an empty list, not a fault.
|
||||
*/
|
||||
router.get("/:id/repos", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
res.json(await nestedRepos(lane.cwd, { refresh: req.query.refresh === "1" }));
|
||||
} catch {
|
||||
res.json({ root: false, items: [], truncated: false });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id/git", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
@@ -2426,14 +2444,25 @@ function gitErrorCode(err) {
|
||||
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
|
||||
* `sessionWorktree` from the session ROW (owned by this workspace, registered
|
||||
* by `git worktree list`), or — with `?source=<id>` — one of the workspace's
|
||||
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never
|
||||
* from a path in the request. An unresolvable session (400) or source (404,
|
||||
* like another workspace's row) is never a silent fall back to the main
|
||||
* folder: a commit or a discard must never land in the wrong tree.
|
||||
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW, or —
|
||||
* with `?repo=<path>` — a repo inside the workspace folder, accepted only when
|
||||
* `repoFolder`'s own walk of that folder found it; never from a path in the
|
||||
* request. An unresolvable session (400), source or repo (404, like another
|
||||
* workspace's row) is never a silent fall back to the main folder: a commit
|
||||
* or a discard must never land in the wrong tree.
|
||||
*/
|
||||
async function gitTargetOr404(req, res) {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return null;
|
||||
if (req.query.repo !== undefined) {
|
||||
try {
|
||||
const cwd = await repoFolder(lane.cwd, req.query.repo);
|
||||
return { lane, cwd, worktree: null, source: true };
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: { code: err.code, message: err.message } });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (req.query.source !== undefined) {
|
||||
try {
|
||||
const cwd = sourceFolder(lane.id, req.query.source);
|
||||
|
||||
+7
-2
@@ -6,7 +6,7 @@
|
||||
* the setup wizard shows once per account. Local mode is one account,
|
||||
* `local`; server mode keys a row `u<id>` by the login cookie — the request
|
||||
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
|
||||
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
|
||||
* string of at most 8 KB (the sidebar's workspace groups 64 KB), and a PUT merges into the row (`null` removes a
|
||||
* key). Writes are same-origin and touch only the caller's own row.
|
||||
* `/instructions` is the account's own agent instructions (issue #67, at most
|
||||
* 16 000 characters, blank clears), put before the workspace's in every pane
|
||||
@@ -68,8 +68,13 @@ const PREF_KEYS = [
|
||||
"agenthub.chatUi",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
"agenthub.workspace.groups",
|
||||
"agenthub.changes.innerRepos",
|
||||
];
|
||||
const MAX_VALUE = 8 * 1024;
|
||||
// The sidebar's workspace groups (lane id → group, JSON) outgrow 8 KB long
|
||||
// before any other key would.
|
||||
const MAX_FOR = { "agenthub.workspace.groups": 64 * 1024 };
|
||||
|
||||
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
|
||||
function userKey(req) {
|
||||
@@ -122,7 +127,7 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
|
||||
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
|
||||
}
|
||||
if (v === null) delete prefs[k];
|
||||
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
|
||||
else if (typeof v === "string" && v.length <= (MAX_FOR[k] ?? MAX_VALUE)) prefs[k] = v;
|
||||
else
|
||||
return res
|
||||
.status(400)
|
||||
|
||||
Reference in New Issue
Block a user