chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update

- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
This commit is contained in:
2026-10-23 11:26:12 +07:00
parent 9dd97a369c
commit 572d615d4e
9 changed files with 122 additions and 62 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.21 (2026-10-23) — Claude Docs docs and session documents (.pptx/.docx/.xlsx/.pdf/.html) land in Artifacts, unowned rows are claimed; a plugin update reports its running step (`plugin_update_progress`); a hidden update or clone leaves a progress chip._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.22 (2026-10-23) — Users' today/7/30-day spend is booked like the cost routes (every tool counts); a page adopts a running plugin update and keeps its progress chip._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+27 -9
View File
@@ -19,9 +19,12 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error. While hidden, a small chip in the
* corner keeps showing the update — with the plugin chain's current step from
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -242,13 +245,14 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
@@ -344,6 +348,19 @@ export function UpdateNotifier() {
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
@@ -406,8 +423,9 @@ export function UpdateNotifier() {
})
: null;
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
@@ -353,6 +353,39 @@ describe("UpdateNotifier — hidden while it runs", () => {
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.21 (rebuild client to match)
UI build v2.8.22 (rebuild client to match)
</span>
</p>
</div>
+2 -1
View File
@@ -160,7 +160,8 @@ Workspaces made before server mode, or by the host account, have none.
An account's last login is kept in `users.last_login_at`, so it still shows
after the login itself has ended.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days — the
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
**Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it.
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.22",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.22",
"bin": {
"agenthub": "bin/agenthub.js"
},
+14 -1
View File
@@ -57,10 +57,22 @@ before(async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
// A tool with no hourly rows (omp, Codex…): its total counts from its start.
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('so', 'mia omp', 'active', '/w/mia', ?, 'omp')"
).run(lane);
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('so', 'claude-sonnet-4-5-20250929', 500000)`
).run();
});
after(() => {
@@ -76,7 +88,8 @@ describe("staff views", () => {
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.equal(mia.sessions, 2);
assert.equal(mia.today.tokens, 1500000, "a session with no hourly rows counts too");
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
// This hour's spend is today's in every timezone.
assert.equal(mia.today.cost, mia.last7d.cost);
+41 -46
View File
@@ -4,7 +4,7 @@
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
* `GET /` lists every account with its workspaces, sessions and $ spent today
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
* and in the last 7 and 30 days (from `token_usage_hourly`, sessions owned
* and in the last 7 and 30 days (`hourlyTokenRows` + `calculateCost`, sessions owned
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
@@ -42,22 +42,6 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
const router = Router();
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",
];
// Which account each session belongs to — through its workspace, or a
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
const OWNED_SESSIONS = `
@@ -69,33 +53,43 @@ const OWNED_SESSIONS = `
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
WHERE l.owner_id IS NOT NULL`;
/** Map(user id → {cost, tokens}) every account's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
function usageSince(sinceHour, rules) {
const { calculateCost } = require("./pricing");
const rows = db
.prepare(
`WITH owned AS (${OWNED_SESSIONS})
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
WHERE h.hour >= ?
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(sinceHour);
const byUser = new Map();
for (const r of rows) {
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
byUser.get(r.uid).push(r);
/**
* Map(user id → {cost, tokens}) per window, each window a UTC hour
* (`YYYY-MM-DDTHH`) it starts at. Spend is the pricing routes' own booking
* (`hourlyTokenRows`: a session's hours, the rest of its total at its start
* hour — so Codex/omp/Antigravity and sub-agent usage count too) priced by
* `calculateCost`, never a second formula.
*/
function usageSince(windows, rules) {
const { calculateCost, hourlyTokenRows } = require("./pricing");
const oldest = windows.reduce((a, b) => (b < a ? b : a));
const owner = new Map();
for (const r of db.prepare(OWNED_SESSIONS).all()) {
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
}
const out = new Map();
for (const [uid, list] of byUser) {
const tokens = list.reduce(
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
0
);
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
}
return out;
// A session that spent in the window was touched in it (budgets.js does the same).
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
[oldest]
);
return windows.map((since) => {
const byUser = new Map();
for (const r of rows) {
const uid = owner.get(r.session_id);
if (uid == null || r.date < since) continue;
if (!byUser.has(uid)) byUser.set(uid, []);
byUser.get(uid).push(r);
}
const out = new Map();
for (const [uid, list] of byUser) {
const tokens = list.reduce(
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
0
);
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens: Math.round(tokens) });
}
return out;
});
}
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
@@ -125,9 +119,10 @@ router.get("/", (req, res) => {
const sessions = counts(
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
);
const today = usageSince(todayHour(parseInt(req.query.tz_offset, 10)), rules);
const last7d = usageSince(hourAgo(7), rules);
const last30d = usageSince(hourAgo(30), rules);
const [today, last7d, last30d] = usageSince(
[todayHour(parseInt(req.query.tz_offset, 10)), hourAgo(7), hourAgo(30)],
rules
);
const none = { cost: 0, tokens: 0 };
const users = listUsers().map((u) => ({
...u,