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
+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. */