fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_` for every non-ASCII character, and a macOS LaunchAgent starts the dashboard with no locale at all — Vietnamese text and Claude Code's logo came out as underscores in every browser terminal. - shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS, C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and Windows are left alone - RunSetup.tsx: the `/` list now offers Claude Code's bundled skills (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and every enabled plugin's commands and skills as /<plugin>:<name> - Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached per token), not by .claude.json: a running session can leave the credentials store holding one account's token under another account's name. The Profiles modal, its Check quota and the status-bar quota now show the account requests are really billed to and warn on the mismatch; a switch saves the outgoing token into its OWNER's profile (credentials only) instead of overwriting the profile .claude.json names; saving a profile while they disagree is refused (EMISMATCH); a saved profile holding another account's token reports EWRONGACCOUNT instead of that account's usage - Running Claude Code sessions follow a switch on their own (they re-read the credentials store when it changes), so the "close your sessions first" warning is gone
This commit is contained in:
@@ -121,6 +121,7 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
|
||||
@@ -47,12 +47,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "builtin";
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||
// typed into the pane like any other text, so they do run — they are just not
|
||||
// files anyone can list.
|
||||
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
||||
// API lists the built-ins, add names here as they ship.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
@@ -62,6 +65,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"usage",
|
||||
"context",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
@@ -69,14 +74,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"reload-plugins",
|
||||
"skills",
|
||||
"output-style",
|
||||
"resume",
|
||||
"rewind",
|
||||
"add-dir",
|
||||
"doctor",
|
||||
"export",
|
||||
"login",
|
||||
"logout",
|
||||
"statusline",
|
||||
"theme",
|
||||
"fast",
|
||||
"exit",
|
||||
"code-review",
|
||||
"security-review",
|
||||
"simplify",
|
||||
"loop",
|
||||
"schedule",
|
||||
"fewer-permission-prompts",
|
||||
"update-config",
|
||||
"claude-api",
|
||||
"run",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
plugin: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
@@ -185,8 +212,26 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
const fromPlugins = pl.plugins
|
||||
.filter((p) => p.enabled !== false && p.contributes)
|
||||
.flatMap((p) =>
|
||||
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
||||
(it): SlashCommand => ({
|
||||
name: `${p.name}:${it.name}`,
|
||||
source: "plugin",
|
||||
description: it.description || undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
||||
if (live)
|
||||
setCommands(
|
||||
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
|
||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
plugins: r({
|
||||
plugins: [
|
||||
{
|
||||
name: "vercel",
|
||||
enabled: true,
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off",
|
||||
enabled: false,
|
||||
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
// Claude Code's bundled skills, matched on a word of their name.
|
||||
expect.stringContaining("/code-review"),
|
||||
expect.stringContaining("/security-review"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the session will start in, not the server's own.
|
||||
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers enabled plugins' skills under their plugin's name", async () => {
|
||||
renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/deplo" } });
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
|
||||
});
|
||||
|
||||
it("suggests files under the folder on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
||||
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
|
||||
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
|
||||
"activeLabel": "Logged in now",
|
||||
"noActive": "No Claude account is logged in on this machine.",
|
||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
|
||||
@@ -258,7 +258,8 @@
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
|
||||
"warning": "Các phiên Claude Code đang chạy tự đi theo khi đổi: chúng nhận đăng nhập mới trong khoảng 30 giây.",
|
||||
"mismatch": "Đăng nhập đang dùng thuộc tài khoản {{owner}}, nhưng config của Claude Code lại ghi {{config}}. Mọi request đang tính vào {{owner}}. Bấm Chuyển sang đúng tài khoản bạn muốn để sửa.",
|
||||
"activeLabel": "Đang đăng nhập",
|
||||
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
|
||||
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
|
||||
@@ -1770,7 +1770,13 @@ export const api = {
|
||||
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
|
||||
*/
|
||||
profiles: () =>
|
||||
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
|
||||
request<{
|
||||
items: CcProfile[];
|
||||
active: CcAccountIdentity | null;
|
||||
/** Set when the live token belongs to a different account than the
|
||||
* one `.claude.json` names; `active` is then the token's owner. */
|
||||
mismatch?: { configEmail: string | null } | null;
|
||||
}>("/cc-config/profiles"),
|
||||
/**
|
||||
* POST /api/cc-config/profiles - snapshot the logged-in account as a
|
||||
* profile. The identifier and initial label are derived from the account;
|
||||
|
||||
@@ -4763,6 +4763,8 @@ function ProfilesModal({
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
@@ -4787,6 +4789,7 @@ function ProfilesModal({
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
@@ -4931,10 +4934,18 @@ function ProfilesModal({
|
||||
|
||||
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("profiles.warning")}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
|
||||
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.8 (rebuild client to match)
|
||||
UI build v2.5.9 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+6
-4
@@ -2039,7 +2039,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", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], 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"`. **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). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>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.
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], 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). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>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.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
@@ -2363,13 +2363,15 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
|
||||
|
||||
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
|
||||
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
|
||||
|
||||
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
|
||||
|
||||
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
|
||||
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against the live token's owner (the config's account only when the owner is unknown) and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
|
||||
|
||||
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
|
||||
|
||||
@@ -2377,7 +2379,7 @@ A profile has two names, and the split is what lets a profile be called `Tài kh
|
||||
|
||||
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
|
||||
|
||||
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
|
||||
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost. Under a `mismatch` that snapshot goes to the profile of the token's OWNER and replaces only its `credentials` (its label and identity stay) — never into the profile the config names, which would hand that profile another account's token — and it is taken even when the owner's profile is the switch target, so the switch writes the newest token that account has. It returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `EMISMATCH` (409 — `POST /profiles` while the live token belongs to another account than the config names; switch to that account's profile, or log in again), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A running Claude Code session follows a switch by itself: it re-reads the credentials store when the Keychain item's version or `.credentials.json`'s mtime changes (checked about every 30 s in Claude Code 2.1.x), so nobody has to close sessions first. If one still writes its old account back, the token-owner rule above shows the truth and a switch repairs it.
|
||||
|
||||
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.5.8
|
||||
version: 2.5.9
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.8",
|
||||
"version": "2.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.5.8",
|
||||
"version": "2.5.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.8",
|
||||
"version": "2.5.9",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -384,3 +384,63 @@ describe("cc-profiles", () => {
|
||||
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
|
||||
});
|
||||
});
|
||||
|
||||
// The state a running session can leave behind: `.claude.json` still names one
|
||||
// account while the credentials store holds ANOTHER account's token. The token
|
||||
// is what Claude Code actually uses, so its owner (asked of Anthropic by the
|
||||
// route, passed in here) decides who is logged in.
|
||||
describe("cc-profiles — identity and token disagree", () => {
|
||||
/** Thắng and Victor saved; then Victor's token lands under Thắng's identity. */
|
||||
function mixedLogin() {
|
||||
login("uuid-t", "t@example.com");
|
||||
profiles.saveProfile("thang");
|
||||
login("uuid-v", "v@example.com");
|
||||
profiles.saveProfile("victor");
|
||||
login("uuid-t", "t@example.com");
|
||||
fs.writeFileSync(
|
||||
CREDS,
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: "at-v-new", refreshToken: "rt-v-new", expiresAt: 222 },
|
||||
})
|
||||
);
|
||||
return { uuid: "uuid-v", email: "v@example.com" };
|
||||
}
|
||||
const stored = (name) => profiles.readProfile(name);
|
||||
|
||||
it("marks the token's owner active and reports the disagreement", () => {
|
||||
const owner = mixedLogin();
|
||||
const { items, active, mismatch } = profiles.listProfiles({ owner });
|
||||
assert.deepEqual(
|
||||
items.filter((p) => p.active).map((p) => p.name),
|
||||
["victor"]
|
||||
);
|
||||
assert.equal(active.email, "v@example.com");
|
||||
assert.equal(mismatch.configEmail, "t@example.com");
|
||||
// Without an owner (offline) the config is all there is, as before.
|
||||
assert.equal(profiles.listProfiles().mismatch, null);
|
||||
});
|
||||
|
||||
it("a switch saves the outgoing token into its OWNER's profile, never the one config names", () => {
|
||||
const owner = mixedLogin();
|
||||
profiles.activateProfile("thang", { owner });
|
||||
assert.equal(stored("victor").credentials.refreshToken, "rt-v-new");
|
||||
assert.equal(stored("victor").account.emailAddress, "v@example.com");
|
||||
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
|
||||
// And the switch itself landed: Thắng's token and identity, together.
|
||||
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-t");
|
||||
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-t");
|
||||
});
|
||||
|
||||
it("switching to the owner's own profile keeps the live (newest) token and fixes the identity", () => {
|
||||
const owner = mixedLogin();
|
||||
profiles.activateProfile("victor", { owner });
|
||||
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.refreshToken, "rt-v-new");
|
||||
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-v");
|
||||
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
|
||||
});
|
||||
|
||||
it("refuses to save a profile while they disagree", () => {
|
||||
const owner = mixedLogin();
|
||||
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* window mapped to the status bar's label/percent/reset, and a window that has
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed.
|
||||
* with its own token only while it is valid, never refreshed, and never shown
|
||||
* with the usage of a token that belongs to a different account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -83,6 +84,10 @@ test("a saved profile is asked with its own token only while valid, never refres
|
||||
const asked = [];
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url, opts) => {
|
||||
if (url.endsWith("/profile")) {
|
||||
// The fresh profile's token is its own account's.
|
||||
return { ok: true, json: async () => ({ account: { uuid: "fresh" } }) };
|
||||
}
|
||||
asked.push(`${url} ${opts.headers.Authorization}`);
|
||||
return {
|
||||
status: 200,
|
||||
@@ -107,3 +112,103 @@ test("a saved profile is asked with its own token only while valid, never refres
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a profile holding another account's token shows EWRONGACCOUNT, not that account's usage", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-owner-"));
|
||||
const dir = path.join(home, "agenthub-profiles");
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "victor.json"),
|
||||
JSON.stringify({
|
||||
label: "Victor",
|
||||
account: { accountUuid: "victor", emailAddress: "victor@x.y" },
|
||||
credentials: { accessToken: "tok", refreshToken: "r", expiresAt: Date.now() + 3600_000 },
|
||||
})
|
||||
);
|
||||
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url) => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () =>
|
||||
url.endsWith("/profile")
|
||||
? { account: { uuid: "thang" } }
|
||||
: { five_hour: { utilization: 30, resets_at: null } },
|
||||
});
|
||||
try {
|
||||
__resetCache();
|
||||
const [victor] = (await quotas()).tools.filter((t) => t.id === "claude");
|
||||
assert.strictEqual(victor.label, "Victor");
|
||||
assert.strictEqual(victor.error, "EWRONGACCOUNT");
|
||||
assert.deepStrictEqual(victor.windows, []);
|
||||
} finally {
|
||||
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prev.home;
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-live-"));
|
||||
const home = path.join(root, ".claude");
|
||||
const dir = path.join(home, "agenthub-profiles");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const save = (name, label, uuid) =>
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
label,
|
||||
account: { accountUuid: uuid, emailAddress: `${name}@x.y` },
|
||||
credentials: { accessToken: `tok-${name}`, refreshToken: "r", expiresAt: 1 },
|
||||
})
|
||||
);
|
||||
save("thang", "Thắng", "uuid-t");
|
||||
save("victor", "Victor", "uuid-v");
|
||||
// Thắng's identity, Victor's token.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
JSON.stringify({ oauthAccount: { accountUuid: "uuid-t", emailAddress: "thang@x.y" } })
|
||||
);
|
||||
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url) => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () =>
|
||||
url.endsWith("/profile")
|
||||
? { account: { uuid: "uuid-v", email: "victor@x.y" } }
|
||||
: { five_hour: { utilization: 82, resets_at: null } },
|
||||
});
|
||||
try {
|
||||
__resetCache();
|
||||
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
|
||||
const live = claude.find((t) => t.active);
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
);
|
||||
} finally {
|
||||
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prev.home;
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* @file Tests for server/lib/shell-path.js — login-shell PATH hydration with a
|
||||
* stubbed spawner (noisy rc output, errors, opt-out, dedupe) plus one real
|
||||
* `/bin/sh` run to prove the argv works end to end.
|
||||
* `/bin/sh` run to prove the argv works end to end, and the UTF-8 locale
|
||||
* default that keeps tmux from printing `_` for every non-ASCII character.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hydrateShellPath, shellPath } = require("../lib/shell-path");
|
||||
const { hydrateShellPath, shellPath, ensureUtf8Locale } = require("../lib/shell-path");
|
||||
|
||||
const M = "__AGENTHUB_PATH__";
|
||||
const stub =
|
||||
@@ -69,3 +70,27 @@ describe("hydrateShellPath", () => {
|
||||
assert.ok(process.env.PATH.startsWith("/usr/bin:/bin"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureUtf8Locale", () => {
|
||||
it("sets LC_CTYPE when no locale is set (a macOS LaunchAgent)", () => {
|
||||
const env = {};
|
||||
ensureUtf8Locale(env, "darwin");
|
||||
assert.equal(env.LC_CTYPE, "en_US.UTF-8");
|
||||
const linux = { LANG: "C" };
|
||||
ensureUtf8Locale(linux, "linux");
|
||||
assert.equal(linux.LC_CTYPE, "C.UTF-8");
|
||||
});
|
||||
|
||||
it("leaves a UTF-8 locale, an explicit LC_ALL and Windows alone", () => {
|
||||
for (const [env, platform] of [
|
||||
[{ LANG: "vi_VN.UTF-8" }, "darwin"],
|
||||
[{ LC_CTYPE: "en_US.utf8" }, "linux"],
|
||||
[{ LC_ALL: "C" }, "darwin"],
|
||||
[{}, "win32"],
|
||||
]) {
|
||||
const before = { ...env };
|
||||
ensureUtf8Locale(env, platform);
|
||||
assert.deepEqual(env, before);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,6 +356,10 @@ function startBackgroundServices() {
|
||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||
autoImportLegacySessions();
|
||||
|
||||
// Before any tmux call: without a UTF-8 locale every attach prints `_` for
|
||||
// non-ASCII text (a macOS LaunchAgent sets none).
|
||||
require("./lib/shell-path").ensureUtf8Locale();
|
||||
|
||||
// The service's PATH is whatever its unit captured at install time; read the
|
||||
// login shell's so a CLI installed since is detected and reaches lane panes.
|
||||
require("./lib/shell-path")
|
||||
|
||||
+63
-11
@@ -248,12 +248,14 @@ function freeName(base) {
|
||||
* terminal.
|
||||
*
|
||||
* @param stored a profile as returned by `readProfile`
|
||||
* @param opts.owner the live token's owner (`cc-usage.liveOwner`), when known
|
||||
* @returns the live `claudeAiOauth` block, or null when this is not that account
|
||||
*/
|
||||
function liveCredentialsFor(stored) {
|
||||
function liveCredentialsFor(stored, { owner = null } = {}) {
|
||||
const live = readActiveAuth();
|
||||
if (!live.oauth) return null;
|
||||
const mine = identityOf(live).accountUuid;
|
||||
// The token's owner when known: `.claude.json` can name another account.
|
||||
const mine = owner?.uuid || identityOf(live).accountUuid;
|
||||
const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid;
|
||||
return mine && mine === theirs ? live.oauth : null;
|
||||
}
|
||||
@@ -269,8 +271,25 @@ function readProfile(name) {
|
||||
* @returns `{ items, active }` — `items[].active` marks the one whose
|
||||
* `accountUuid` matches the live config. Tokens are never included.
|
||||
*/
|
||||
function listProfiles() {
|
||||
const active = identityOf(readActiveAuth());
|
||||
function listProfiles({ owner = null } = {}) {
|
||||
const live = readActiveAuth();
|
||||
const configured = identityOf(live);
|
||||
// The token is what Claude Code runs as; `.claude.json` is only a label, and
|
||||
// a running session can leave it naming a different account.
|
||||
const mismatch =
|
||||
owner?.uuid && configured.accountUuid && owner.uuid !== configured.accountUuid
|
||||
? { configEmail: configured.email, configUuid: configured.accountUuid }
|
||||
: null;
|
||||
const active = mismatch
|
||||
? {
|
||||
...configured,
|
||||
accountUuid: owner.uuid,
|
||||
email: owner.email || null,
|
||||
displayName: null,
|
||||
organization: null,
|
||||
seatTier: null,
|
||||
}
|
||||
: configured;
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
@@ -304,7 +323,17 @@ function listProfiles() {
|
||||
// Label first (that is what the UI shows), name as the tiebreaker so two
|
||||
// profiles sharing a label still come back in a stable order.
|
||||
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
||||
return { items, active: active.accountUuid || active.email ? active : null };
|
||||
// The owner's own saved identity reads better than the bare uuid + email.
|
||||
const mine = mismatch && items.find((p) => p.active);
|
||||
const shown = mine
|
||||
? {
|
||||
...active,
|
||||
displayName: mine.displayName,
|
||||
organization: mine.organization,
|
||||
seatTier: mine.seatTier,
|
||||
}
|
||||
: active;
|
||||
return { items, active: shown.accountUuid || shown.email ? shown : null, mismatch };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,7 +350,7 @@ function listProfiles() {
|
||||
* afterwards instead of having to invent a filename up front.
|
||||
* @returns `{ ok, name, label, ...identity }`
|
||||
*/
|
||||
function saveProfile(name) {
|
||||
function saveProfile(name, { owner = null } = {}) {
|
||||
const auth = readActiveAuth();
|
||||
if (!auth.oauth) {
|
||||
throw err(
|
||||
@@ -329,6 +358,15 @@ function saveProfile(name) {
|
||||
"no Claude account is logged in on this machine - run `claude auth login` first"
|
||||
);
|
||||
}
|
||||
const configUuid = auth.account && auth.account.accountUuid;
|
||||
if (owner?.uuid && configUuid && owner.uuid !== configUuid) {
|
||||
throw err(
|
||||
"EMISMATCH",
|
||||
`the token logged in now belongs to ${owner.email || "another account"}, not ${
|
||||
auth.account.emailAddress || configUuid
|
||||
} - switch to that account's profile, or run \`claude auth login\` again`
|
||||
);
|
||||
}
|
||||
const identity = identityOf(auth);
|
||||
const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile";
|
||||
// An explicit name is both identifier and initial label; a derived one keeps
|
||||
@@ -405,22 +443,36 @@ function renameProfile(name, label) {
|
||||
* @param name Profile to activate.
|
||||
* @returns `{ ok, name, savedOutgoing, written }`
|
||||
*/
|
||||
function activateProfile(name) {
|
||||
const target = readProfile(name);
|
||||
if (!target || !target.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
function activateProfile(name, { owner = null } = {}) {
|
||||
if (!readProfile(name)?.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
|
||||
const current = readActiveAuth();
|
||||
let savedOutgoing = null;
|
||||
if (current.oauth) {
|
||||
const mine = listProfiles().items.find((p) => p.active);
|
||||
const { items, mismatch } = listProfiles({ owner });
|
||||
const mine = items.find((p) => p.active);
|
||||
if (!mine) {
|
||||
throw err(
|
||||
"EUNSAVED",
|
||||
"the account logged in right now is not saved as a profile - save it first, or its credentials are lost on switch"
|
||||
);
|
||||
}
|
||||
if (mine.name !== name) savedOutgoing = saveProfile(mine.name).name;
|
||||
if (mismatch) {
|
||||
// The live token belongs to `mine`, but `.claude.json` names someone
|
||||
// else: keep that profile's own identity and take only the token — the
|
||||
// newest one that account has (a refresh retired the saved copy). Done
|
||||
// even when `mine` IS the target, so the switch writes that newest token.
|
||||
writeProfile(mine.name, {
|
||||
...readProfile(mine.name),
|
||||
credentials: current.oauth,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
savedOutgoing = mine.name;
|
||||
} else if (mine.name !== name) {
|
||||
savedOutgoing = saveProfile(mine.name).name;
|
||||
}
|
||||
}
|
||||
const target = readProfile(name);
|
||||
|
||||
const written = [];
|
||||
const { creds: live, keychain } = readCredentials();
|
||||
|
||||
+49
-2
@@ -46,6 +46,7 @@ const profiles = require("./cc-profiles");
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
|
||||
/** Treat a token expiring within this window as already expired: a refresh is
|
||||
@@ -154,6 +155,44 @@ async function fetchUsage(accessToken) {
|
||||
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
|
||||
}
|
||||
|
||||
/** Token → owner. An access token never changes hands, so a hit never goes stale. */
|
||||
const owners = new Map();
|
||||
|
||||
/**
|
||||
* The account a token belongs to — `{uuid, email}` — or null when the profile
|
||||
* endpoint does not say. A saved profile's label and its token come from two
|
||||
* different files (`oauthAccount` and the credentials store), and a running
|
||||
* Claude Code session can leave them naming different accounts, so the token's
|
||||
* own answer is the only one that says who a request will be billed to.
|
||||
*/
|
||||
async function fetchOwner(accessToken) {
|
||||
if (owners.has(accessToken)) return owners.get(accessToken);
|
||||
try {
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const account = (await res.json())?.account;
|
||||
if (!account?.uuid) return null;
|
||||
const owner = { uuid: account.uuid, email: account.email || null };
|
||||
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
||||
owners.set(accessToken, owner);
|
||||
return owner;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Who the CLI's live token belongs to; null when offline, expired or not OAuth. */
|
||||
async function liveOwner() {
|
||||
const { oauth } = profiles.readActiveAuth();
|
||||
if (!oauth?.accessToken || !(Number(oauth.expiresAt) > Date.now() + EXPIRY_MARGIN_MS)) {
|
||||
return null;
|
||||
}
|
||||
return fetchOwner(oauth.accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage for one saved profile, refreshing its token first when that token has
|
||||
* expired (which, for any profile that is not the active one, it usually has).
|
||||
@@ -171,7 +210,7 @@ async function profileUsage(name) {
|
||||
// since, retiring the profile's. Use what the CLI holds, and never refresh
|
||||
// it: that would retire the token the user's own terminal is using, and this
|
||||
// module does not write `.credentials.json` back.
|
||||
const live = profiles.liveCredentialsFor(stored);
|
||||
const live = profiles.liveCredentialsFor(stored, { owner: await liveOwner() });
|
||||
const creds = live || stored.credentials || {};
|
||||
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
if (live && !fresh) {
|
||||
@@ -200,4 +239,12 @@ async function profileUsage(name) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
|
||||
module.exports = {
|
||||
profileUsage,
|
||||
fetchUsage,
|
||||
fetchOwner,
|
||||
refreshProfile,
|
||||
CLIENT_ID,
|
||||
USAGE_URL,
|
||||
TOKEN_URL,
|
||||
};
|
||||
|
||||
+42
-12
@@ -11,7 +11,11 @@
|
||||
* while that token is valid and never refreshed (a refresh rotates the
|
||||
* saved credentials); past expiry it shows its last figure, `asOf`. Cached
|
||||
* `CLAUDE_TTL_MS` per account, so a poll from every open tab is one
|
||||
* upstream call each. The live entry is `active: true`.
|
||||
* upstream call each. The live entry is `active: true`. Each token's owner
|
||||
* is asked too (`fetchOwner`): the live row is named after the account its
|
||||
* token belongs to (`.claude.json` can name another), and a saved profile
|
||||
* whose token belongs to a different account answers `EWRONGACCOUNT`,
|
||||
* never that account's usage.
|
||||
* - **Codex**: no network. Every `token_count` event Codex writes into its
|
||||
* rollout journal carries the account's `rate_limits`; the newest one wins.
|
||||
*
|
||||
@@ -28,7 +32,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage } = require("./cc-usage");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
@@ -71,6 +75,17 @@ function claudeWindows(u) {
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** A token's windows — unless it belongs to another account than `uuid`. */
|
||||
async function usageFor(token, uuid) {
|
||||
const [usage, owner] = await Promise.all([fetchUsage(token), fetchOwner(token)]);
|
||||
if (uuid && owner && owner.uuid !== uuid) {
|
||||
throw Object.assign(new Error("this sign-in belongs to another account"), {
|
||||
code: "EWRONGACCOUNT",
|
||||
});
|
||||
}
|
||||
return claudeWindows(usage);
|
||||
}
|
||||
|
||||
/** The account the CLI is logged into, with the token it holds right now. */
|
||||
async function liveClaude() {
|
||||
let who = {};
|
||||
@@ -82,7 +97,17 @@ async function liveClaude() {
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
return { ...who, windows: claudeWindows(await fetchUsage(oauth.accessToken)) };
|
||||
// The token's owner, not `.claude.json`, is who this usage is billed to.
|
||||
const [usage, owner] = await Promise.all([
|
||||
fetchUsage(oauth.accessToken),
|
||||
fetchOwner(oauth.accessToken),
|
||||
]);
|
||||
return {
|
||||
...who,
|
||||
account: owner?.email || who.account,
|
||||
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
||||
windows: claudeWindows(usage),
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
@@ -105,7 +130,7 @@ async function profileClaude(item) {
|
||||
}
|
||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||
try {
|
||||
const windows = claudeWindows(await fetchUsage(creds.accessToken));
|
||||
const windows = await usageFor(creds.accessToken, item.accountUuid);
|
||||
lastGood.set(item.name, { windows, at: Date.now() });
|
||||
return { ...who, windows };
|
||||
} catch (e) {
|
||||
@@ -130,19 +155,24 @@ async function claudeQuotas(force) {
|
||||
} catch {
|
||||
/* no profiles dir */
|
||||
}
|
||||
const [live, ...others] = await Promise.all([
|
||||
cached("live", force, liveClaude),
|
||||
...items
|
||||
.filter((it) => !it.active)
|
||||
const live = await cached("live", force, liveClaude);
|
||||
// The live row is the profile of whoever the token belongs to.
|
||||
const mine = live?.ownerUuid
|
||||
? items.find((it) => it.accountUuid === live.ownerUuid)
|
||||
: items.find((it) => it.active);
|
||||
const others = await Promise.all(
|
||||
items
|
||||
.filter((it) => it !== mine)
|
||||
.map(async (it) => ({
|
||||
id: "claude",
|
||||
active: false,
|
||||
label: it.label,
|
||||
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
||||
})),
|
||||
]);
|
||||
const mine = items.find((it) => it.active);
|
||||
return [live && { id: "claude", active: true, label: mine?.label || null, ...live }, ...others];
|
||||
}))
|
||||
);
|
||||
if (!live) return others;
|
||||
const { ownerUuid: _uuid, ...shown } = live;
|
||||
return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others];
|
||||
}
|
||||
|
||||
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. The shell's own
|
||||
* PATH is also kept in ITS order (`shellPath()`): the merge appends, so an
|
||||
* install job that must use the shell's npm puts this first. Adapted from Orca's
|
||||
* `hydrate-shell-path.ts` (MIT).
|
||||
* `hydrate-shell-path.ts` (MIT). `ensureUtf8Locale` fills the other gap such
|
||||
* a service leaves: no locale at all, which makes every `tmux attach` print
|
||||
* `_` for each non-ASCII character.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -56,4 +58,20 @@ function hydrateShellPath({
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { hydrateShellPath, shellPath };
|
||||
/**
|
||||
* A LaunchAgent (and some service managers) start the dashboard with no
|
||||
* LC_ALL/LC_CTYPE/LANG, and a tmux client whose first set one does not name
|
||||
* UTF-8 replaces every non-ASCII character with `_` — Vietnamese text and
|
||||
* Claude Code's block-art logo in every browser terminal. Default LC_CTYPE
|
||||
* (charset only, no message/date changes) so the attach clients, and the
|
||||
* panes of a tmux server started from here, inherit it. An explicit LC_ALL is
|
||||
* the user's choice and wins; Windows (psmux) is left alone. macOS has no
|
||||
* C.UTF-8, and glibc systems may not have en_US.UTF-8 generated.
|
||||
*/
|
||||
function ensureUtf8Locale(env = process.env, platform = process.platform) {
|
||||
if (platform === "win32" || env.LC_ALL) return;
|
||||
if (/UTF-?8/i.test(env.LC_CTYPE || env.LANG || "")) return;
|
||||
env.LC_CTYPE = platform === "darwin" ? "en_US.UTF-8" : "C.UTF-8";
|
||||
}
|
||||
|
||||
module.exports = { hydrateShellPath, shellPath, ensureUtf8Locale };
|
||||
|
||||
@@ -531,6 +531,7 @@ const PROFILE_ERR_TO_STATUS = {
|
||||
ENOAUTH: 409,
|
||||
EDUPLICATE: 409,
|
||||
ELABELTAKEN: 409,
|
||||
EMISMATCH: 409,
|
||||
};
|
||||
|
||||
function profileError(res, e) {
|
||||
@@ -538,17 +539,21 @@ function profileError(res, e) {
|
||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
router.get("/profiles", (_req, res) => {
|
||||
// The live token's owner (asked of Anthropic, cached per token) decides who is
|
||||
// logged in — `.claude.json` can name another account. Null offline: then the
|
||||
// config is all there is, as before.
|
||||
router.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
res.json(ccProfiles.listProfiles());
|
||||
res.json(ccProfiles.listProfiles({ owner: await ccUsage.liveOwner() }));
|
||||
} catch (e) {
|
||||
return profileError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/profiles", (req, res) => {
|
||||
router.post("/profiles", async (req, res) => {
|
||||
try {
|
||||
const result = ccProfiles.saveProfile((req.body || {}).name);
|
||||
const owner = await ccUsage.liveOwner();
|
||||
const result = ccProfiles.saveProfile((req.body || {}).name, { owner });
|
||||
emitChanged({ action: "save", type: "profile", name: result.name, project: null });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
@@ -568,9 +573,10 @@ router.patch("/profiles", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/profiles/activate", (req, res) => {
|
||||
router.post("/profiles/activate", async (req, res) => {
|
||||
try {
|
||||
const result = ccProfiles.activateProfile((req.body || {}).name);
|
||||
const owner = await ccUsage.liveOwner();
|
||||
const result = ccProfiles.activateProfile((req.body || {}).name, { owner });
|
||||
emitChanged({ action: "activate", type: "profile", name: result.name, project: null });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user