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
+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,
};