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
+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: {},