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:
@@ -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