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
+44 -10
View File
@@ -27,21 +27,33 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
function run(id, account, { status = "completed", session = null, started } = {}) {
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), account);
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, ended_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), ended, account);
}
function spend(session, tokens) {
const MODEL = "claude-sonnet-4-5-20250929";
const hoursAgo = (h) => new Date(Date.now() - h * 3600e3).toISOString();
/**
* `tokens` output tokens of `session` spent `atHoursAgo` hours ago — the
* hourly row plus the session total, as the hook writer keeps them both.
* `{hourly: false}` = a total with no hourly row (a sub-agent's bucket).
*/
function spend(session, tokens, { atHoursAgo = 0, hourly = true, model = MODEL } = {}) {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
).run(session, session);
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, started_at) VALUES (?, ?, 'completed', '/tmp', ?)"
).run(session, session, hoursAgo(atHoursAgo));
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(session, new Date().toISOString().slice(0, 13), tokens);
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(session, model, tokens);
if (!hourly) return;
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)`
).run(session, hoursAgo(atHoursAgo).slice(0, 13), model, tokens);
}
before(() => {
@@ -62,6 +74,8 @@ describe("claude pool", () => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM token_usage_hourly").run();
db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM sessions").run();
pool.__quotaCache.clear();
});
@@ -109,6 +123,26 @@ describe("claude pool", () => {
assert.equal(pool.pickAccount().name, "b");
});
it("books a session to an account only for the hours its runs held it, sub-agents included", () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
// s1 ran on a 30 h ago, then was resumed on b 2 h ago.
run("r-a", "a", { session: "s1", started: hoursAgo(30), ended: hoursAgo(29) });
run("r-b", "b", { session: "s1", started: hoursAgo(2), status: "running" });
spend("s1", 1000, { atHoursAgo: 30 });
spend("s1", 3000, { atHoursAgo: 2 });
// s2 (on a, now): its main agent and a Haiku sub-agent with no hourly row.
run("r-a2", "a", { session: "s2" });
spend("s2", 500);
spend("s2", 200, { hourly: false, model: "claude-haiku-4-5-20251001" });
const status = Object.fromEntries(pool.poolStatus().map((x) => [x.name, x]));
assert.equal(status.a.tokens5h, 700, "s2, sub-agent included; s1 is b's now");
assert.equal(status.a.tokens7d, 1700);
assert.equal(status.b.tokens5h, 3000, "not the hours a held s1");
assert.equal(status.b.tokens7d, 3000);
assert.ok(status.a.spend7d > status.a.spend5h && status.a.spend5h > 0);
assert.ok(status.b.spend5h > 0);
});
it("puts an account whose quota is nearly used up last", async () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
ccUsage.fetchUsage = async (token) => ({