Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 572d615d4e |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -19,9 +19,12 @@
|
|||||||
* background process can do for it. Escape, the backdrop and × close it for
|
* 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
|
* 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
|
* 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
|
* failure re-opens the modal with its error. While hidden — or once the status
|
||||||
* corner keeps showing the update — with the plugin chain's current step from
|
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||||
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
|
* 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
|
* 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
|
* 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
|
* 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?.latest_release ||
|
||||||
status?.remote_sha ||
|
status?.remote_sha ||
|
||||||
(installed ? `installed:${installed.root}` : null);
|
(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(
|
const show = Boolean(
|
||||||
isAdmin &&
|
isAdmin &&
|
||||||
status?.update_available &&
|
|
||||||
dismissKey &&
|
dismissKey &&
|
||||||
dismissedSha !== dismissKey &&
|
closedKey !== dismissKey &&
|
||||||
closedKey !== dismissKey
|
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||||
);
|
);
|
||||||
const busy = applying || restarting || fixing;
|
const busy = applying || restarting || fixing;
|
||||||
|
|
||||||
@@ -344,6 +348,19 @@ export function UpdateNotifier() {
|
|||||||
[syncFromPayload, t]
|
[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 () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -406,8 +423,9 @@ export function UpdateNotifier() {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
|
// Hidden while it runs — closed, or the status no longer offers the update
|
||||||
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
|
// (the new version is already on disk): a chip keeps it in sight.
|
||||||
|
if (!show && isAdmin && (applying || restarting)) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -353,6 +353,39 @@ describe("UpdateNotifier — hidden while it runs", () => {
|
|||||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
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 () => {
|
it("shows no chip when nothing is running", async () => {
|
||||||
render(<UpdateNotifier />);
|
render(<UpdateNotifier />);
|
||||||
await screen.findByText(i18n.t("updates:title"));
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
|||||||
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.21 (rebuild client to match)
|
UI build v2.8.22 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -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
|
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||||
after the login itself has ended.
|
after the login itself has ended.
|
||||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
- `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
|
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
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
|
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.21",
|
"version": "2.8.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.21",
|
"version": "2.8.22",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.21",
|
"version": "2.8.22",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,10 +57,22 @@ before(async () => {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
|
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
|
||||||
).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||||
|
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
|
||||||
|
).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||||
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
|
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
|
||||||
).run(new Date().toISOString().slice(0, 13));
|
).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(() => {
|
after(() => {
|
||||||
@@ -76,7 +88,8 @@ describe("staff views", () => {
|
|||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
const mia = r.body.users.find((u) => u.username === "mia");
|
const mia = r.body.users.find((u) => u.username === "mia");
|
||||||
assert.equal(mia.workspaces, 1);
|
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);
|
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
||||||
// This hour's spend is today's in every timezone.
|
// This hour's spend is today's in every timezone.
|
||||||
assert.equal(mia.today.cost, mia.last7d.cost);
|
assert.equal(mia.today.cost, mia.last7d.cost);
|
||||||
|
|||||||
+31
-36
@@ -4,7 +4,7 @@
|
|||||||
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
||||||
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
||||||
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
|
* (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
|
* 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/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).
|
* (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 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
|
// 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).
|
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||||
const OWNED_SESSIONS = `
|
const OWNED_SESSIONS = `
|
||||||
@@ -69,23 +53,32 @@ 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
|
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`;
|
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) {
|
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||||
const { calculateCost } = require("./pricing");
|
* (`YYYY-MM-DDTHH`) it starts at. Spend is the pricing routes' own booking
|
||||||
const rows = db
|
* (`hourlyTokenRows`: a session's hours, the rest of its total at its start
|
||||||
.prepare(
|
* hour — so Codex/omp/Antigravity and sub-agent usage count too) priced by
|
||||||
`WITH owned AS (${OWNED_SESSIONS})
|
* `calculateCost`, never a second formula.
|
||||||
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
|
*/
|
||||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
function usageSince(windows, rules) {
|
||||||
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
|
const { calculateCost, hourlyTokenRows } = require("./pricing");
|
||||||
WHERE h.hour >= ?
|
const oldest = windows.reduce((a, b) => (b < a ? b : a));
|
||||||
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
|
const owner = new Map();
|
||||||
)
|
for (const r of db.prepare(OWNED_SESSIONS).all()) {
|
||||||
.all(sinceHour);
|
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||||
|
}
|
||||||
|
// 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();
|
const byUser = new Map();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
|
const uid = owner.get(r.session_id);
|
||||||
byUser.get(r.uid).push(r);
|
if (uid == null || r.date < since) continue;
|
||||||
|
if (!byUser.has(uid)) byUser.set(uid, []);
|
||||||
|
byUser.get(uid).push(r);
|
||||||
}
|
}
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (const [uid, list] of byUser) {
|
for (const [uid, list] of byUser) {
|
||||||
@@ -93,9 +86,10 @@ function usageSince(sinceHour, rules) {
|
|||||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
|
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens: Math.round(tokens) });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||||
@@ -125,9 +119,10 @@ router.get("/", (req, res) => {
|
|||||||
const sessions = counts(
|
const sessions = counts(
|
||||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
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 [today, last7d, last30d] = usageSince(
|
||||||
const last7d = usageSince(hourAgo(7), rules);
|
[todayHour(parseInt(req.query.tz_offset, 10)), hourAgo(7), hourAgo(30)],
|
||||||
const last30d = usageSince(hourAgo(30), rules);
|
rules
|
||||||
|
);
|
||||||
const none = { cost: 0, tokens: 0 };
|
const none = { cost: 0, tokens: 0 };
|
||||||
const users = listUsers().map((u) => ({
|
const users = listUsers().map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
|
|||||||
Reference in New Issue
Block a user