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:
2026-10-07 11:18:10 +07:00
parent 7543b8d62a
commit 8fdda66893
24 changed files with 504 additions and 59 deletions
+63 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+20 -2
View File
@@ -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 };