feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+489 -9
View File
@@ -382,6 +382,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
);
}
/**
* A file uploaded from the user's computer: kept as a snapshot like a pinned
* one, under a name made from the upload's own (no folder, no path).
*/
function uploadFile(req, { name, data, title, description }) {
if (!Buffer.isBuffer(data) || !data.length) throw fail("ENOFILE", "the file is empty");
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
// eslint-disable-next-line no-control-regex
const clean = base.replace(/[\x00-\x1f/]/g, "").replace(/^\.+/, "").slice(0, 200) || "file";
const snap = newSnapshotDir();
try {
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
return view(
insert({
kind: "file",
title: cleanTitle(title, clean),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: clean,
snapshot: snap.id,
files: JSON.stringify([{ path: clean, size: data.length }]),
size: data.length,
}),
req
);
}
// ── skill ─────────────────────────────────────────────────────────────────
/**
@@ -499,16 +531,45 @@ function addSkill(req, { name, laneId, title, description }) {
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
}
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
const { files, total } = walkSkill(real);
const { files } = walkSkill(real);
const runAs = require("./run-as");
return storeSkill(req, {
name,
title,
description,
laneId: lane ? lane.id : null,
files: files.map((f) => ({
path: f.path,
data: () => runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
})),
});
}
/** `description:` of a SKILL.md's frontmatter, or "". */
function frontmatterField(text, field) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${field}:\\s*(.*)$`, "m").exec(fm[1]);
return (m?.[1] || "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
/**
* Snapshot a skill's files and record the row. `files`: `[{path, data}]`,
* `data` a Buffer or a function returning one (read only while copying).
*/
function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
total += data.length;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
mode: 0o600,
});
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
}
} catch (err) {
dropSnapshot(snap.id);
@@ -516,8 +577,7 @@ function addSkill(req, { name, laneId, title, description }) {
}
let summary = "";
try {
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
} catch {
/* no frontmatter */
}
@@ -527,16 +587,263 @@ function addSkill(req, { name, laneId, title, description }) {
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: lane ? lane.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
files: JSON.stringify(recorded),
size: total,
}),
req
);
}
// ── skill: written or uploaded ────────────────────────────────────────────
// Junk an OS or an editor leaves in a folder or a zip: skipped, never an error.
const JUNK_RE = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const ZIP_RE = /\.(zip|skill)$/i;
const TEXT_RE = /\.(md|markdown|txt)$/i;
const ZIP_MAX_ENTRIES = 2000;
/**
* A skill folder name from free text: what the user typed, a frontmatter
* `name:`, a folder or a file name. Spaces and other characters become `-`;
* Vietnamese letters lose their marks. "" when nothing usable is left.
*/
function skillSlug(text) {
return String(text || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
function validSkillName(raw) {
const name = skillSlug(raw);
if (!SKILL_NAME_RE.test(name)) {
throw fail("EBADNAME", "skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)");
}
return name;
}
/** One uploaded path, made safe: posix, relative, no `..`, no secret, not too deep. */
function cleanUploadPath(raw) {
const p = String(raw || "")
.replace(/\\/g, "/")
.replace(/^(\.\/)+/, "")
.replace(/^\/+/, "");
// eslint-disable-next-line no-control-regex
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
const parts = p.split("/").filter(Boolean);
if (parts.some((x) => x === ".." || x === ".")) throw fail("EBADPATH", `not a path inside the skill: ${p}`);
if (parts.length > SKILL_MAX_DEPTH + 2) throw fail("ETOOBIG", "the skill folder is nested too deep");
return parts.join("/");
}
/** A zip's (or a .skill's) regular files: sizes checked from its directory before inflating. */
function unzipSkill(buf) {
const AdmZip = require("adm-zip");
let entries;
try {
entries = new AdmZip(buf).getEntries();
} catch {
throw fail("EBADZIP", "not a readable zip file");
}
if (entries.length > ZIP_MAX_ENTRIES) throw fail("ETOOBIG", "the zip has too many entries");
let total = 0;
const out = [];
for (const e of entries) {
if (e.isDirectory || JUNK_RE.test(e.entryName)) continue;
const mode = (e.attr >>> 16) & 0o170000;
if (mode === 0o120000) throw fail("ESYMLINK", `the zip holds a symlink: ${e.entryName}`);
total += e.header.size;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const data = e.getData();
if (data.length !== e.header.size) throw fail("EBADZIP", `a damaged zip entry: ${e.entryName}`);
out.push({ path: e.entryName, data });
}
return out;
}
/**
* Upload → skills. Every case a user may hand over:
* - one Markdown/text file (any name) → that file is the skill's SKILL.md;
* - a folder or several files with a SKILL.md (at the top or in one folder
* under it) → that folder is the skill;
* - a .zip / .skill archive → unpacked, then the same rules;
* - several skill folders at once (each with its own SKILL.md) → one
* artifact per skill, named by its folder.
* `name` (the user's) names the skill when there is exactly one.
*/
function skillsFromUpload(uploads, { name, description }) {
let files = [];
for (const u of uploads) {
const p = cleanUploadPath(u.path);
if (JUNK_RE.test(p)) continue;
if (ZIP_RE.test(p) && uploads.length === 1) {
files.push(...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data })));
} else {
files.push({ path: p, data: u.data });
}
}
files = files.filter((f) => !JUNK_RE.test(f.path));
if (!files.length) throw fail("ENOFILE", "nothing to add: the upload is empty");
const seen = new Set();
for (const f of files) {
if (seen.has(f.path)) throw fail("EBADPATH", `the same path twice: ${f.path}`);
seen.add(f.path);
}
const isSkillMd = (p) => path.posix.basename(p).toLowerCase() === "skill.md";
let roots = files.filter((f) => isSkillMd(f.path)).map((f) => path.posix.dirname(f.path));
if (!roots.length) {
if (files.length === 1 && TEXT_RE.test(files[0].path)) {
const only = files[0];
const stem = path.posix.basename(only.path).replace(/\.[^.]+$/, "");
return [
{
fallbackName: frontmatterField(only.data.toString("utf8"), "name") || stem,
files: [{ path: "SKILL.md", data: only.data }],
},
];
}
throw fail(
"ENOSKILLMD",
"no SKILL.md in the upload — add one, or upload a single Markdown file to use as SKILL.md"
);
}
// A SKILL.md inside another skill's folder belongs to that skill.
roots = [...new Set(roots)].sort((a, b) => a.length - b.length);
const under = (dir, p) => dir === "." || p === dir || p.startsWith(`${dir}/`);
roots = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
return roots.map((root) => {
const own = files
.filter((f) => under(root, f.path))
.map((f) => ({
path: root === "." ? f.path : f.path.slice(root.length + 1),
data: f.data,
}))
.map((f) => (f.path.toLowerCase() === "skill.md" ? { ...f, path: "SKILL.md" } : f));
const md = own.find((f) => f.path === "SKILL.md");
const folder = root === "." ? "" : path.posix.basename(root);
return {
fallbackName: frontmatterField(md.data.toString("utf8"), "name") || folder,
files: own,
};
});
}
/** Check one skill's files like a folder on disk is checked. */
function checkSkillFiles(files) {
if (files.length > SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
let total = 0;
for (const f of files) {
if (f.path.split("/").length > SKILL_MAX_DEPTH + 1) {
throw fail("ETOOBIG", "the skill folder is nested too deep");
}
if (SECRET_NAME_RE.test(path.posix.basename(f.path))) {
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${f.path}`);
}
total += f.data.length;
}
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
}
/**
* SKILL.md with the chosen `name:` and a `description:` in its frontmatter —
* Claude Code reads both (the description decides when the skill is used).
* A file with no frontmatter gets one; an existing one keeps every other line.
*/
function withFrontmatter(text, name, description) {
const body = String(text).replace(/^\uFEFF/, "");
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
const desc = String(description || "").trim().replace(/\s+/g, " ");
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
if (!fm) {
if (!desc) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\nname: ${name}\ndescription: ${quote(desc)}\n---\n\n${body.replace(/^\s+/, "")}`;
}
let lines = fm[1].split(/\r?\n/);
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
lines = has("name")
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
: [`name: ${name}`, ...lines];
if (desc) {
lines = has("description")
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
: [...lines, `description: ${quote(desc)}`];
} else if (!frontmatterField(body, "description")) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\n${lines.join("\n")}\n---\n${body.slice(fm[0].length)}`;
}
/** Finish one skill: its name, its SKILL.md frontmatter, the checks, the snapshot. */
function saveSkillFiles(req, { name, files, title, description }) {
const md = files.find((f) => f.path === "SKILL.md");
const text = md.data.toString("utf8");
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
checkSkillFiles(all);
return storeSkill(req, { name, files: all, title, description });
}
/**
* Add skills from uploaded files (multipart: `files` + their relative
* `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes.
*/
function uploadSkills(req, { uploads, name, title, description }) {
const found = skillsFromUpload(uploads || [], { name, description });
const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
files: sk.files,
}));
const dup = prepared.map((p) => p.name).find((n, i, a) => a.indexOf(n) !== i);
if (dup) throw fail("EBADNAME", `two skills in the upload are both named ${dup}`);
for (const p of prepared) {
const md = p.files.find((f) => f.path === "SKILL.md");
// Checked up front so a bad second skill stores nothing.
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files);
}
return prepared.map((p) =>
saveSkillFiles(req, {
name: p.name,
files: p.files,
title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "",
})
);
}
/** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name);
const text = typeof content === "string" ? content : "";
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
return saveSkillFiles(req, {
name: n,
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
title,
description,
});
}
// ── plugin ────────────────────────────────────────────────────────────────
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
@@ -550,9 +857,164 @@ function validMarket(market) {
}
}
const MARKET_JSON_MAX = 1024 * 1024;
const MARKET_TIMEOUT_MS = 45_000;
let inspectImpl = null; // tests: (market) => marketplace.json text
/**
* What a user pasted, made into a marketplace source Claude Code takes:
* `owner/repo`, a git URL (a forge's web link `…/src/branch/main`,
* `…/tree/main` or a trailing slash trimmed off), or an https link to a
* `marketplace.json`.
*/
function normalizeMarket(raw) {
let m = typeof raw === "string" ? raw.trim() : "";
const gh = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(m);
if (gh && GITHUB_REPO_RE.test(gh[1])) return gh[1];
if (/^https:\/\//.test(m) && !/\.json$/i.test(m)) {
m = m.replace(/\/(?:src|tree|blob)\/.*$/, "").replace(/\/+$/, "");
}
return validMarket(m);
}
/** An https URL git can clone for `market` (owner/repo = GitHub), or null (ssh). */
function cloneUrl(market) {
if (GITHUB_REPO_RE.test(market)) return `https://github.com/${market}.git`;
return market.startsWith("https://") ? market : null;
}
/** The text of a marketplace's `.claude-plugin/marketplace.json`. */
async function fetchMarketJson(market) {
if (inspectImpl) return inspectImpl(market);
if (/^https:\/\/.*\.json$/i.test(market)) {
let res;
try {
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
} catch (e) {
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
}
if (!res.url.startsWith("https://")) throw fail("EMARKETFETCH", "the link redirected off https");
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
const text = await res.text();
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
return text;
}
const url = cloneUrl(market);
if (!url) {
throw fail(
"ESSHMARKET",
"an ssh link cannot be read from here — paste the https link to list its plugins, or type name@marketplace by hand"
);
}
const { git } = require("./worktree");
const tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-market-"));
// No login is ever sent: the machine's credential helpers are switched off,
// so a URL a browser names never receives a token stored for another use.
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
const safe = [
"-c", "credential.helper=",
"-c", "protocol.allow=never",
"-c", "protocol.https.allow=always",
"-c", "core.hooksPath=/dev/null",
];
try {
try {
await git(tmp, [...safe, "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", "--", url, "repo"], {
literal: true,
timeout: MARKET_TIMEOUT_MS,
env,
});
} catch (e) {
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
if (/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)) {
throw fail(
"ENEEDLOGIN",
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
);
}
if (/not found|does not exist|404/i.test(why)) throw fail("EMARKETFETCH", "no repository at that link");
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
}
const repo = path.join(tmp, "repo");
const show = (p) =>
git(repo, ["show", `HEAD:${p}`], { literal: true, timeout: MARKET_TIMEOUT_MS, env, maxBuffer: MARKET_JSON_MAX }).then(
(r) => (typeof r === "string" ? r : r.stdout),
() => null
);
const text = await show(".claude-plugin/marketplace.json");
if (text != null) return text;
if ((await show(".claude-plugin/plugin.json")) != null) {
throw fail(
"ENOTMARKET",
"this repository is one plugin, not a marketplace — Claude Code installs plugins from a marketplace (a repo with .claude-plugin/marketplace.json). Add one to it, or share its skills as Skill artifacts"
);
}
throw fail("ENOTMARKET", "no .claude-plugin/marketplace.json in this repository");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
/**
* Read a marketplace and list its plugins:
* `{ market, name, description, plugins: [{ name, key, description, version, category }] }`.
*/
async function inspectMarket(raw) {
const market = normalizeMarket(raw);
const text = await fetchMarketJson(market);
let json;
try {
json = JSON.parse(text);
} catch {
throw fail("EBADMARKET", "marketplace.json is not valid JSON");
}
const name = typeof json?.name === "string" ? json.name.trim() : "";
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
throw fail("EBADMARKET", "marketplace.json has no usable name");
}
const str = (v, n = DESCRIPTION_MAX) => (typeof v === "string" ? v.trim().slice(0, n) : "");
const plugins = (Array.isArray(json.plugins) ? json.plugins : [])
.filter((p) => p && typeof p.name === "string" && PLUGIN_RE.test(`${p.name.trim()}@${name}`))
.slice(0, 500)
.map((p) => ({
name: p.name.trim(),
key: `${p.name.trim()}@${name}`,
description: str(p.description),
version: str(p.version, 50),
category: str(p.category, 50),
}));
return { market, name, description: str(json.description || json.metadata?.description), plugins };
}
function __setInspectImpl(fn) {
inspectImpl = fn;
}
/**
* Add a plugin by `name@marketplace` and its marketplace source. The same
* plugin added again by its owner updates that row instead of a duplicate.
*/
function addPlugin(req, { key, market, title, description }) {
const k = typeof key === "string" ? key.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
const ownerId = req?.user ? req.user.id : null;
const src = normalizeMarket(market);
const same = sql("SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?").get(
k,
ownerId
);
if (same) {
sql(
`UPDATE artifacts SET market = ?, title = ?, description = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
src,
cleanTitle(title, same.title),
cleanDescription(description) || same.description,
same.id
);
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
}
return view(
insert({
kind: "plugin",
@@ -560,7 +1022,7 @@ function addPlugin(req, { key, market, title, description }) {
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: k,
market: validMarket(market),
market: src,
}),
req
);
@@ -595,6 +1057,16 @@ function update(req, id, body = {}) {
sets.push("description = ?");
params.push(cleanDescription(body.description));
}
if (row.kind === "plugin" && "source" in body) {
const k = typeof body.source === "string" ? body.source.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
sets.push("source = ?");
params.push(k);
}
if (row.kind === "plugin" && "market" in body) {
sets.push("market = ?");
params.push(normalizeMarket(body.market));
}
let shareChange = null;
if ("shared" in body) {
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
@@ -771,6 +1243,13 @@ module.exports = {
pinFile,
availableSkills,
addSkill,
uploadSkills,
writeSkill,
uploadFile,
skillSlug,
withFrontmatter,
normalizeMarket,
inspectMarket,
addPlugin,
update,
remove,
@@ -779,4 +1258,5 @@ module.exports = {
installsEnabled,
setInstallsEnabled,
__setExecImpl,
__setInspectImpl,
};
+3
View File
@@ -142,6 +142,8 @@ const publicUser = (u) =>
disabled: Boolean(u.disabled),
displayName: u.display_name ?? null,
email: u.email ?? null,
// When the avatar was last set (null = none): the client's cache-buster.
avatarAt: u.avatar_at ?? null,
createdAt: u.created_at ?? null,
};
@@ -349,6 +351,7 @@ function deleteUser(username) {
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
require("./avatars").removeAvatar(u.id);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
} catch {
+71
View File
@@ -0,0 +1,71 @@
/**
* @file avatars.js
* @description A server-mode account's own avatar (Settings → Account, shown
* in the sidebar). One raster image per account, stored as
* `<data dir>/avatars/u<id>.<png|jpg|gif|webp>` — a name made from the
* account id and the type its magic bytes say, never from the request — and
* `users.avatar_at` records when it was set, so the client can version the
* URL. Raster only: an SVG served from the dashboard's origin runs script.
* At most `MAX_AVATAR_BYTES`; the serving route adds `nosniff` and a
* `sandbox` CSP like every other user-supplied file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const { sniffImage } = require("./pets");
const MAX_AVATAR_BYTES = 1024 * 1024;
const EXTS = ["png", "jpg", "gif", "webp"];
const TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
const fail = (code, message) => Object.assign(new Error(message), { code });
const avatarsDir = () => path.join(getDataDir(), "avatars");
function fileName(userId, ext) {
const id = Number(userId);
if (!Number.isSafeInteger(id) || id <= 0) throw fail("EBADFIELD", "bad account id");
return path.join(avatarsDir(), `u${id}.${ext}`);
}
const db = () => require("../db").db;
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
function removeAvatar(userId) {
for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true });
db().prepare("UPDATE users SET avatar_at = NULL WHERE id = ?").run(Number(userId));
}
/** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */
function setAvatar(userId, buf) {
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EBADIMAGE", "no image sent");
if (buf.length > MAX_AVATAR_BYTES) throw fail("ETOOBIG", "the image is too large");
const ext = sniffImage(buf);
if (!ext) throw fail("EBADIMAGE", "only PNG, JPEG, GIF or WebP images");
const target = fileName(userId, ext);
fs.mkdirSync(avatarsDir(), { recursive: true, mode: 0o700 });
// Written beside, then renamed: a reader never sees half an image.
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, buf, { mode: 0o600 });
fs.renameSync(tmp, target);
for (const other of EXTS) {
if (other !== ext) fs.rmSync(fileName(userId, other), { force: true });
}
const at = new Date().toISOString();
db().prepare("UPDATE users SET avatar_at = ? WHERE id = ?").run(at, Number(userId));
return at;
}
/** The account's avatar file and its type, or null when it has none. */
function avatarFile(userId) {
for (const ext of EXTS) {
const file = fileName(userId, ext);
const st = fs.lstatSync(file, { throwIfNoEntry: false });
if (st?.isFile()) return { path: file, type: TYPES[ext] };
}
return null;
}
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
+18 -53
View File
@@ -15,9 +15,9 @@
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
* window goes last (still used when every account is that full);
* 2. fewest live panes (spreads concurrent sessions);
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
* started.
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
@@ -375,47 +375,8 @@ function quotaPercent(name) {
// ── What AgentHub recorded per account ─────────────────────────────────────
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"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",
];
/**
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
* usage of every session one of its runs started, from that run's start hour.
* ponytail: a session later resumed on ANOTHER account keeps counting here
* too; split by run end time if that ever skews a ranking.
*/
function spendSince(account, sinceHour) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const rows = db
.prepare(
`WITH s AS (
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
FROM dashboard_runs
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
GROUP BY session_id)
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM s JOIN token_usage_hourly h
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(account, sinceHour);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
/** $ and tokens of the last 5 h / 7 d (pool-usage.js). */
const usageOf = (account) => require("./pool-usage").accountUsage("claude", account);
function livePanes(account) {
const { db } = require("../db");
@@ -426,8 +387,6 @@ function livePanes(account) {
.get(account).n;
}
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
/** Every account with what the balancer sees — never a token. */
function poolStatus() {
return allAccounts().map((a) => {
@@ -442,8 +401,7 @@ function poolStatus() {
expiresAt: expiresAt(a),
expired: expired(a),
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, hourAgo(5)),
spend7d: spendSince(a.name, hourAgo(24 * 7)),
...usageOf(a.name),
quota: q
? {
fiveHour: q.fiveHour || null,
@@ -481,8 +439,11 @@ function pick(member) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
if (!member) {
// A host pane's home holds the host's own login.
usable = usable.filter((a) => a.kind === "token");
// A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk:
// the pane then runs on it as it is, and its usage is that account's.
const disk = homeCreds(HOST_ACCT);
usable = usable.filter((a) => a.kind === "token" || ownsHome(a, HOST, disk));
} else {
const disk = homeCreds(member);
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
@@ -501,13 +462,14 @@ function pick(member) {
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
const u = usageOf(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, hourAgo(5)),
spendSince(a.name, hourAgo(24 * 7)),
u.spend5h,
u.spend7d,
],
};
})
@@ -524,7 +486,8 @@ function pick(member) {
* The account a new Claude pane should run on — `{name, kind, token?}` — or
* null when the pool has nothing this pane may use. Synchronous (a spawn
* cannot wait on the network): it ranks on the cached quota and starts a
* background re-read. A `login` is only ever offered for a member.
* background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is.
*/
function pickAccount(member = null) {
const a = pick(member);
@@ -545,6 +508,8 @@ function forSpawn(member = null) {
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
}
// The host's own login is already in place; the server keeps it fresh.
if (!member) return { name: a.name, env: {}, place: () => {} };
return {
name: a.name,
env: {},
+7 -24
View File
@@ -155,29 +155,12 @@ function livePanes(account) {
}
/**
* $ of the Codex sessions this account's runs started since `hours` ago.
* $ and tokens of the last 5 h / 7 d (pool-usage.js).
* ponytail: Codex usage has no hourly table, so a session counts whole once
* its run started in the window; add hourly rows if that skews a ranking.
*/
function spendSince(account, hours) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const since = new Date(Date.now() - hours * 3600e3).toISOString();
const rows = db
.prepare(
`SELECT model, speed, inference_geo, service_tier,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_read_tokens) AS cache_read_tokens,
SUM(cache_write_tokens) AS cache_write_tokens
FROM token_usage
WHERE session_id IN (SELECT session_id FROM dashboard_runs
WHERE tool = 'codex' AND account = ?
AND session_id IS NOT NULL AND started_at >= ?)
GROUP BY model, speed, inference_geo, service_tier`
)
.all(account, since);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
const usageOf = (account) =>
require("./pool-usage").accountUsage("codex", account, { wholeSession: true });
// ── Quota, from the account's own rollouts ─────────────────────────────────
@@ -262,8 +245,7 @@ function poolStatus() {
kind: a.kind,
addedAt: a.addedAt,
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, 5),
spend7d: spendSince(a.name, 24 * 7),
...usageOf(a.name),
quota: quotas().get(a.name) || null,
quotaPercent: percent,
full: percent != null && percent >= BLOCK_PERCENT,
@@ -374,13 +356,14 @@ function pick(member) {
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
const u = usageOf(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, 5),
spendSince(a.name, 24 * 7),
u.spend5h,
u.spend7d,
],
};
})
+10 -5
View File
@@ -736,12 +736,17 @@ function inferredDisplayStage(lane) {
return lane.detected_stage;
}
/** The owner's username and display name — the staff sidebar groups
* workspaces by owner and shows the display name when one is set. */
/** The owner's username, display name and avatar version — the staff
* sidebar groups workspaces by owner and shows the display name and picture
* (`GET /api/users/:id/avatar`) when one is set. */
function ownerNames(id) {
if (id == null) return { owner_name: null, owner_display_name: null };
const u = db.prepare("SELECT username, display_name FROM users WHERE id = ?").get(id);
return { owner_name: u?.username ?? null, owner_display_name: u?.display_name ?? null };
if (id == null) return { owner_name: null, owner_display_name: null, owner_avatar_at: null };
const u = db.prepare("SELECT username, display_name, avatar_at FROM users WHERE id = ?").get(id);
return {
owner_name: u?.username ?? null,
owner_display_name: u?.display_name ?? null,
owner_avatar_at: u?.avatar_at ?? null,
};
}
function lanePayload(lane, ageSec = null) {
+1
View File
@@ -333,5 +333,6 @@ module.exports = {
removePet,
spriteFile,
webpSize,
sniffImage,
__setFetch,
};
+64
View File
@@ -0,0 +1,64 @@
/**
* @file pool-usage.js
* @description $ and tokens one account-pool account spent in the last 5 hours
* and 7 days, for the balancer and `GET /api/accounts` (claude-pool.js,
* codex-pool.js). Tokens are booked to hours by `hourlyTokenRows` — the same
* booking as the daily cost split, so sub-agent buckets and pre-compaction
* baselines count — and priced by `calculateCost`, never a second formula.
* A Claude session counts only for the hours one of THIS account's runs held
* it (start hour to end hour), so a session resumed on another account is
* not booked twice; Codex has no hourly rows, so its session counts whole
* once a run of the account started in the window, as before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const WINDOWS = { "5h": 5, "7d": 24 * 7 };
const hourAgo = (hours, now) => new Date(now - hours * 3600e3).toISOString().slice(0, 13);
const tokensOf = (r) =>
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
/**
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
* `wholeSession`, a session's every row counts in a window its run started in.
*/
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
const { db, stmts } = require("../db");
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
const runs = db
.prepare(
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?`
)
.all(tool, account, since7d);
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
if (runs.length === 0) return out;
const runsOf = new Map();
for (const r of runs) runsOf.set(r.session_id, [...(runsOf.get(r.session_id) || []), r]);
const rows = hourlyTokenRows(
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?)`,
[tool, account, since7d]
);
const rules = stmts.listPricing.all();
for (const [label, hours] of Object.entries(WINDOWS)) {
const sinceHour = hourAgo(hours, now);
const sinceIso = new Date(now - hours * 3600e3).toISOString();
const held = rows.filter((row) =>
(runsOf.get(row.session_id) || []).some((run) =>
wholeSession
? run.started_at >= sinceIso
: row.date >= sinceHour && row.date >= run.start_hour && row.date <= run.end_hour
)
);
out[`spend${label}`] = held.length ? calculateCost(held, rules).total_cost : 0;
out[`tokens${label}`] = Math.round(held.reduce((n, r) => n + tokensOf(r), 0));
}
return out;
}
module.exports = { accountUsage };
+172
View File
@@ -0,0 +1,172 @@
/**
* @file user-storage.js
* @description How much disk each server-mode user's HOME folder takes
* (`homes.homeOf`: their own Linux home under S3, their folder under
* AGENTHUB_USERS_ROOT otherwise) — the Users page's Storage column
* (`GET /api/users/storage`). A home is walked in a worker thread, never on
* the event loop (a member decides how many files their home holds), one home
* at a time, under a time limit (`AGENTHUB_STORAGE_TIMEOUT_MS`, default 120 s
* — past it the number is a lower bound, `partial`). The walk is `du`-like and
* read-only: `lstat` only (no file is opened, a symlink is never followed),
* stays on the home's file system, counts allocated blocks (a sparse file
* costs what it really uses) and a hard-linked inode once. Results are cached
* in memory for `FRESH_MS`; a read returns the cache at once and measures the
* stale homes in the background. A user whose "home" is only the host
* account's own (an admin with no folder of their own) is not measured.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
const fs = require("node:fs");
const path = require("node:path");
const FRESH_MS = 15 * 60_000;
/** Walk `root`: `{ bytes, files, dirs, partial }`. Runs in the worker. */
function walk(root, deadline) {
const out = { bytes: 0, files: 0, dirs: 0, partial: false };
let top;
try {
top = fs.lstatSync(root, { bigint: false });
} catch {
return { ...out, partial: true };
}
if (!top.isDirectory()) return out;
const seen = new Set(); // dev:ino of hard-linked files already counted
const stack = [root];
while (stack.length) {
if (Date.now() > deadline) {
out.partial = true;
break;
}
const dir = stack.pop();
let names;
try {
names = fs.readdirSync(dir);
} catch {
out.partial = true; // unreadable (no CAP_DAC_READ_SEARCH, a race): a lower bound
continue;
}
for (const name of names) {
const p = path.join(dir, name);
let st;
try {
st = fs.lstatSync(p);
} catch {
continue; // gone meanwhile
}
if (st.nlink > 1 && !st.isDirectory()) {
const key = `${st.dev}:${st.ino}`;
if (seen.has(key)) continue;
seen.add(key);
}
out.bytes += st.blocks * 512;
if (st.isDirectory()) {
out.dirs += 1;
if (st.dev === top.dev) stack.push(p); // one file system, like du -x
} else {
out.files += 1;
}
}
}
out.bytes += top.blocks * 512;
return out;
}
if (!isMainThread && workerData?.agenthubStorageWorker) {
const { dir, timeoutMs } = workerData;
parentPort.postMessage(walk(dir, Date.now() + timeoutMs));
}
function timeoutMs() {
return Number(process.env.AGENTHUB_STORAGE_TIMEOUT_MS) || 120_000;
}
/** Measure one folder in a fresh worker; never rejects. */
function measure(dir) {
return new Promise((resolve) => {
let w;
const fail = () => resolve({ bytes: 0, files: 0, dirs: 0, partial: true });
try {
w = new Worker(__filename, {
workerData: { agenthubStorageWorker: true, dir, timeoutMs: timeoutMs() },
});
} catch {
return fail();
}
// The walk stops itself at the deadline; this only catches a worker stuck
// inside one syscall (a hung network mount).
const kill = setTimeout(() => {
w.terminate().catch(() => {});
fail();
}, timeoutMs() + 30_000);
kill.unref?.();
w.once("message", (r) => {
clearTimeout(kill);
resolve(r);
w.terminate().catch(() => {});
});
w.once("error", () => {
clearTimeout(kill);
fail();
});
});
}
const cache = new Map(); // dir → { bytes, files, dirs, partial, measuredAt }
const queue = []; // dirs waiting to be measured
let running = null; // dir being measured
function pump() {
if (running || !queue.length) return;
running = queue.shift();
const dir = running;
measure(dir).then((r) => {
cache.set(dir, { ...r, measuredAt: new Date().toISOString() });
running = null;
pump();
});
}
function enqueue(dir) {
if (running === dir || queue.includes(dir)) return;
queue.push(dir);
pump();
}
/**
* The storage of each of `users` (rows from `auth.listUsers()`):
* `{ storage: { [id]: { bytes, files, partial, measuredAt } | null }, measuring }`.
* `null` = nothing to measure (no home of their own) or not measured yet —
* `measuring` says whether more is coming. `refresh` re-measures every home.
*/
function storageFor(users, { refresh = false } = {}) {
const os = require("node:os");
const { homeOf } = require("./homes");
const hostHome = path.resolve(os.homedir());
const storage = {};
let measuring = false;
for (const u of users) {
let dir = null;
try {
dir = homeOf(u);
} catch {
dir = null;
}
if (!dir || path.resolve(dir) === hostHome) {
storage[u.id] = null;
continue;
}
dir = path.resolve(dir);
const hit = cache.get(dir);
const stale = !hit || refresh || Date.now() - Date.parse(hit.measuredAt) > FRESH_MS;
if (stale) enqueue(dir);
if (running === dir || queue.includes(dir)) measuring = true;
storage[u.id] = hit
? { bytes: hit.bytes, files: hit.files, partial: hit.partial, measuredAt: hit.measuredAt }
: null;
}
return { storage, measuring };
}
module.exports = { storageFor, walk, _cache: cache };