diff --git a/CLAUDE.md b/CLAUDE.md index 3d6e6c45..0305472c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.72 (2026-11-04) — an update puts back npm-rewritten lockfiles before its `--ff-only` merge; artifacts drag into folders._ 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.73 (2026-11-04) — Claude quota rows carry `accountUuid`; the Artifacts folder bar is restyled._ 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. diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index 9a5144b2..cf3b2895 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -29,7 +29,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom"; @@ -38,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types"; import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format"; import { AiToolIcon } from "./AiToolIcon"; +import { + HIDDEN_PROFILES_EVENT, + isHiddenProfilesKey, + readHiddenProfiles, + standIn, +} from "../lib/hiddenProfilesStorage"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useSpin } from "../hooks/useSpin"; import { sessionChangeMatcher } from "../lib/sessionChange"; @@ -276,10 +282,44 @@ function QuotaPanel({ ); } +/** The profiles hidden in Settings → AI tools → Profiles, kept current. */ +function useHiddenProfiles(): string[] { + const [hidden, setHidden] = useState(readHiddenProfiles); + useEffect(() => { + const again = () => setHidden(readHiddenProfiles()); + const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again(); + window.addEventListener(HIDDEN_PROFILES_EVENT, again); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(HIDDEN_PROFILES_EVENT, again); + window.removeEventListener("storage", onStorage); + }; + }, []); + return hidden; +} + +/** + * Claude rows without the hidden profiles. When the live login is one of + * them, the first visible profile stands in as the live row — its own usage + * on the chip and the panel — so the panel never names the hidden account. + */ +export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] { + if (!hidden.length) return tools; + const claude = tools.filter((q) => q.id === "claude"); + const isLive = (q: ToolQuota) => q.active !== false; + const decoy = standIn(claude, isLive, hidden); + const keep = (q: ToolQuota) => + q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid); + const shown = tools.filter(keep); + if (!decoy) return shown; + // The stand-in leads the Claude rows, as the live login does. + return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)]; +} + /** The right-end quota chips; nothing at all until a tool reports one. */ function QuotaChips() { const { t } = useTranslation("nav"); - const [tools, setTools] = useState([]); + const [fetched, setTools] = useState([]); const [open, setOpen] = useState(false); // Leaving the chips closes the panel only after a beat, and entering the // panel cancels that: the pointer crosses the gap to read it. @@ -303,6 +343,8 @@ function QuotaChips() { ); const [refreshing, refresh] = useSpin(() => load(true)); useVisiblePoll(load, QUOTA_POLL_MS, [load]); + const hidden = useHiddenProfiles(); + const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]); if (!tools.length) return null; return ( <> diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index 4401911b..71689013 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({ })); import { api } from "../../lib/api"; -import { StatusBar } from "../StatusBar"; +import { StatusBar, withoutHidden } from "../StatusBar"; import { LIVE_RUNS_EVENT } from "../WorkspaceTree"; const at = (url: string, ui: React.JSX.Element) => @@ -307,3 +307,35 @@ describe("StatusBar", () => { expect(api.pricing.quota).toHaveBeenLastCalledWith(true); }); }); + +describe("withoutHidden", () => { + const row = (accountUuid: string, active: boolean, pct: number) => ({ + id: "claude" as const, + active, + accountUuid, + account: `${accountUuid}@x.y`, + windows: [{ label: "5h", usedPercent: pct, resetsAt: null }], + }); + const codex = { id: "codex" as const, active: true, windows: [] }; + + it("leaves the list alone when nothing is hidden", () => { + const list = [row("u1", true, 82), row("u2", false, 19), codex]; + expect(withoutHidden(list, [])).toBe(list); + }); + + it("drops a hidden saved profile", () => { + const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]); + expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]); + }); + + it("puts the first visible profile, with its own usage, in place of a hidden live login", () => { + const out = withoutHidden( + [row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex], + ["u1"] + ); + expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]); + expect(out[0]?.active).toBe(true); + expect(out[0]?.windows[0]?.usedPercent).toBe(19); + expect(out[1]?.active).toBe(false); + }); +}); diff --git a/client/src/components/settings/ToolProfilesSettings.tsx b/client/src/components/settings/ToolProfilesSettings.tsx index 4fce077f..b89f6d10 100644 --- a/client/src/components/settings/ToolProfilesSettings.tsx +++ b/client/src/components/settings/ToolProfilesSettings.tsx @@ -41,6 +41,7 @@ import { toast } from "../../lib/toast"; import { profileKey, readHiddenProfiles, + standIn, toggleHidden, writeHiddenProfiles, } from "../../lib/hiddenProfilesStorage"; @@ -526,6 +527,17 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) writeHiddenProfiles(next); }; const activeHidden = !!active && !reveal && isHidden(active); + /** Shown as the live login while the real one is hidden: the first visible + * profile, with its own usage — a "—" there would only draw the eye. */ + const decoy = + activeHidden && items + ? standIn( + items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })), + (p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid), + hidden + ) + : null; + const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active); const load = useCallback(async () => { try { @@ -622,7 +634,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {t("profiles.mismatch", { config: mismatchEmail, - owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?", + owner: activeHidden + ? decoy?.email || "?" + : active?.email || active?.accountUuid || "?", })}

@@ -639,7 +653,16 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {t("profiles.activeLabel")} {active && activeHidden ? ( -
—
+ decoy ? ( + <> +
+ {decoy.email || decoy.displayName || decoy.accountUuid} +
+ + + ) : ( +
{t("profiles.noActive")}
+ ) ) : active ? ( <>
@@ -650,7 +673,7 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) ) : (
{t("profiles.noActive")}
)} - {active && !activeSaved && ( + {active && !activeSaved && !activeHidden && (
{t("profiles.unsavedActive")}
+ )} + {pooled && } + {deletable(p) && ( + + )} +
+ + void checkUsage(p.name)} /> - {p.active && } - - {p.email || p.displayName || p.accountUuid} - - {!p.active && ( - - )} - {pooled && } - {deletable(p) && ( - + {p.savedAt && ( +
+ {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} +
)}
- - void checkUsage(p.name)} - /> - {p.savedAt && ( -
- {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} -
- )} - - ))} + ))} {ctx.menu && ( setRenaming(p.name))}>{t("profiles.rename")} - {!ctx.menu.item.active && ( + {!shownActive(ctx.menu.item) && !ctx.menu.item.active && ( {t("profiles.switchButton")} diff --git a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx index 43152ef2..e4a12d3e 100644 --- a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx +++ b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx @@ -160,6 +160,19 @@ describe("ToolProfilesSettings — Claude", () => { expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument(); }); + it("shows the first visible profile as the live login while the live one is hidden", async () => { + localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"])); + await show(); + await settle(); + expect(screen.queryByTestId("profile-row-work")).toBeNull(); + expect(screen.queryByText("a@b.c")).toBeNull(); + expect(screen.queryByText("—")).toBeNull(); + const spare = screen.getByTestId("profile-row-spare"); + expect(spare).toHaveTextContent(/active/); + expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull(); + expect(screen.getAllByText("d@e.f").length).toBe(2); + }); + it("does not hide anything in server mode", async () => { perms.local = false; localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"])); diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index d1a75976..8ebc718b 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -262,7 +262,7 @@ "folders.delete": "Delete folder", "folders.deleteTitle": "Delete the folder “{{name}}”?", "folders.deleteBody": "Only the folder goes; its artifacts stay where they are.", - "folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.", + "folders.empty": "This folder is empty. Drag an artifact onto the folder, or use ⋯ → Move to folder.", "folders.move": "Move to folder…", "folders.moveTitle": "Move “{{title}}” to a folder", "folders.private": "Your folders are yours alone: nobody else sees them.", diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index b902bffc..0eb4ed8c 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -260,7 +260,7 @@ "folders.delete": "Xóa thư mục", "folders.deleteTitle": "Xóa thư mục “{{name}}”?", "folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.", - "folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.", + "folders.empty": "Thư mục này trống. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.", "folders.move": "Chuyển vào thư mục…", "folders.moveTitle": "Chuyển “{{title}}” vào thư mục", "folders.private": "Thư mục là của riêng bạn: người khác không thấy.", diff --git a/client/src/lib/hiddenProfilesStorage.ts b/client/src/lib/hiddenProfilesStorage.ts index 57dabb71..42f57a8a 100644 --- a/client/src/lib/hiddenProfilesStorage.ts +++ b/client/src/lib/hiddenProfilesStorage.ts @@ -4,12 +4,17 @@ * AI tools → Profiles, kept in localStorage with the same read/validate/ * fallback convention as laneOrderStorage.ts. A viewer preference only: the * profile files, the API and the database are untouched, and another browser - * still lists every profile. + * still lists every profile. The status bar's Usage panel reads the same list + * (`HIDDEN_PROFILES_EVENT` tells it of a change in this tab), and when the + * live login is hidden both show the first visible profile in its place + * (`standIn`). * * @author Nguyễn Ngọc Trí Vĩ */ const STORAGE_KEY = "agenthub.cc.hiddenProfiles"; +/** Fired on `window` after this tab changes the list. */ +export const HIDDEN_PROFILES_EVENT = "agenthub:hidden-profiles"; /** A profile's stable key: its account (survives a rename), else its file name. */ export function profileKey(p: { accountUuid: string | null; name?: string }): string { @@ -34,6 +39,38 @@ export function writeHiddenProfiles(keys: string[]): void { } catch { /* ignore quota / disabled storage */ } + try { + window.dispatchEvent(new Event(HIDDEN_PROFILES_EVENT)); + } catch { + /* no window (tests outside jsdom) */ + } +} + +/** True when `key` names a change to the hidden list (a `storage` event's key). */ +export const isHiddenProfilesKey = (key: string | null) => key === STORAGE_KEY || key === null; + +/** + * The entry shown in place of a hidden live login: the first one in `list` + * that is not hidden, or null when the live one is visible (or none is left). + */ +export function standIn( + list: T[], + isActive: (x: T) => boolean, + hidden: string[] +): T | null { + const live = list.find(isActive); + if ( + !live || + !hidden.includes(profileKey({ accountUuid: live.accountUuid ?? null, name: live.name })) + ) + return null; + return ( + list.find( + (x) => + !isActive(x) && + !hidden.includes(profileKey({ accountUuid: x.accountUuid ?? null, name: x.name })) + ) ?? null + ); } /** `keys` with `key` added when absent, removed when present. */ diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index ee51aae7..02cfb7af 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1240,6 +1240,8 @@ export interface ToolQuota { active?: boolean; /** The saved profile's label, when the account has one. */ label?: string | null; + /** Claude: the Anthropic account the row is about (hides a profile hidden in Settings). */ + accountUuid?: string | null; /** The signed-in account's email, when the tool's login names one. */ account?: string | null; /** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */ diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index ebd90a8f..15593e07 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -65,9 +65,11 @@ import { LayoutGrid, List, MoreHorizontal, + Pencil, Puzzle, Search, Sparkles, + Trash2, X, } from "lucide-react"; import { @@ -2564,13 +2566,24 @@ function FolderBar({ if (active === f.id) onPick(""); }; const chip = (on: boolean) => - `inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${ - on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary" + `inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${ + on + ? "border-accent/40 bg-accent-muted text-fg-primary" + : "border-border text-fg-secondary hover:border-border-light hover:bg-surface-2 hover:text-fg-primary" }`; - const activeFolder = folders.find((f) => f.id === active); + const count = (n: number, on: boolean) => ( + + {n} + + ); + const iconBtn = + "inline-flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"; return ( -
- +
- {folders.map((f) => ( - - ))} + {folders.map((f) => { + const on = active === f.id; + return editing === f.id ? null : ( + + + {on && !editing && ( + <> + + + + )} + + ); + })} {editing ? ( begin("new")} data-testid="artifact-folder-new" > @@ -2627,24 +2668,6 @@ function FolderBar({ {t("folders.new")} )} - {activeFolder && !editing && ( - - - - - )}
); } diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 10dc4e03..0da856b9 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.72 (rebuild client to match) + UI build v2.8.73 (rebuild client to match)

diff --git a/docs/API.md b/docs/API.md index e5ae2b9b..aea8f0f5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2961,7 +2961,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2 GET /api/pricing/quota[?force=1] ``` -How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. +How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, accountUuid?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. Every Claude row carries its Anthropic `accountUuid` (null when unknown). **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. #### Analytics ```http diff --git a/package-lock.json b/package-lock.json index 400eede6..b25013b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenthub", - "version": "2.8.72", + "version": "2.8.73", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenthub", - "version": "2.8.72", + "version": "2.8.73", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/package.json b/package.json index 9dff303e..48b9f02e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.8.72", + "version": "2.8.73", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/__tests__/quota.test.js b/server/__tests__/quota.test.js index d0dc9111..fcece51e 100644 --- a/server/__tests__/quota.test.js +++ b/server/__tests__/quota.test.js @@ -281,6 +281,8 @@ test("the live row is the account its token belongs to, not the one .claude.json assert.strictEqual(live.label, "Victor"); assert.strictEqual(live.account, "victor@x.y"); assert.strictEqual(live.ownerUuid, undefined); + assert.strictEqual(live.accountUuid, "uuid-v"); + assert.ok(claude.filter((t) => !t.active).every((t) => "accountUuid" in t)); assert.strictEqual(live.plan, "Max"); assert.deepStrictEqual( claude.filter((t) => !t.active).map((t) => t.label), diff --git a/server/lib/quota.js b/server/lib/quota.js index e4c2905c..56369b8b 100644 --- a/server/lib/quota.js +++ b/server/lib/quota.js @@ -210,8 +210,8 @@ async function claudeQuotas(force, home) { liveClaude(() => memberClaudeAuth(home)) ); if (!live) return []; - const { ownerUuid: _uuid, ...shown } = live; - return [{ id: "claude", active: true, label: null, ...shown }]; + const { ownerUuid, ...shown } = live; + return [{ id: "claude", active: true, label: null, accountUuid: ownerUuid || null, ...shown }]; } let items = []; try { @@ -231,12 +231,22 @@ async function claudeQuotas(force, home) { id: "claude", active: false, label: it.label, + accountUuid: it.accountUuid || null, ...(await cached(`profile:${it.name}`, force, () => profileClaude(it))), })) ); if (!live) return others; - const { ownerUuid: _uuid, ...shown } = live; - return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others]; + const { ownerUuid, ...shown } = live; + return [ + { + id: "claude", + active: true, + label: mine?.label || null, + accountUuid: ownerUuid || mine?.accountUuid || null, + ...shown, + }, + ...others, + ]; } /** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */