mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* feat(app): streamline sidebar usage and stacked sheets * fix(app): restore compact sidebar navigation clicks The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript. * fix(app): cap menu sheets at eighty percent height Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import type { UsageInput } from "../shared/input.js";
|
|
import { execFile } from "node:child_process";
|
|
import { existsSync, promises as fs } from "node:fs";
|
|
import { homedir, userInfo } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { z } from "zod";
|
|
import {
|
|
hashAccountKey,
|
|
toneFromUsedPct,
|
|
unavailableUsage,
|
|
windowFromUsedPct,
|
|
type UsageReport,
|
|
type UsageWindow,
|
|
type UsageDetail,
|
|
} from "@getpaseo/plugin/server/usage";
|
|
|
|
const ApiNumberSchema = z.coerce.number().finite();
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
|
|
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
|
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
|
|
const ClaudeCredentialsSchema = z.object({
|
|
claudeAiOauth: z
|
|
.object({
|
|
accessToken: z.string().optional(),
|
|
refreshToken: z.string().optional(),
|
|
subscriptionType: z.string().optional(),
|
|
rateLimitTier: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
const ClaudeUsageWindowSchema = z.object({
|
|
utilization: ApiNumberSchema,
|
|
resets_at: z.string().nullish(),
|
|
});
|
|
|
|
// Model- and surface-scoped weekly limits live in a `limits[]` array rather than a
|
|
// top-level `seven_day_<model>` key. Entries are validated one at a time (see
|
|
// scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down
|
|
// the windows that already parsed from the top-level keys.
|
|
const ClaudeScopeLabelSchema = z
|
|
.object({ id: z.string().nullish(), display_name: z.string().nullish() })
|
|
.nullish();
|
|
|
|
const ClaudeLimitSchema = z.object({
|
|
kind: z.string(),
|
|
percent: ApiNumberSchema.nullish(),
|
|
resets_at: z.string().nullish(),
|
|
scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(),
|
|
});
|
|
|
|
const ClaudeUsageResponseSchema = z.object({
|
|
five_hour: ClaudeUsageWindowSchema.nullish(),
|
|
seven_day: ClaudeUsageWindowSchema.nullish(),
|
|
seven_day_opus: ClaudeUsageWindowSchema.nullish(),
|
|
seven_day_omelette: ClaudeUsageWindowSchema.nullish(),
|
|
// Deliberately permissive: an additive section must never regress the top-level
|
|
// windows, so shape validation happens per entry rather than here.
|
|
limits: z.array(z.unknown()).nullish(),
|
|
extra_usage: z
|
|
.object({
|
|
is_enabled: z.boolean().optional(),
|
|
})
|
|
.nullish(),
|
|
});
|
|
|
|
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
|
|
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
|
|
type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
|
|
|
|
const SCOPED_WEEKLY_KIND = "weekly_scoped";
|
|
|
|
interface ClaudeCredentialRecord {
|
|
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
|
}
|
|
|
|
function buildClaudePlan(
|
|
subscriptionType: string | undefined,
|
|
rateLimitTier: string | undefined,
|
|
): string | null {
|
|
if (!subscriptionType) return null;
|
|
const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
|
|
const tier = rateLimitTier?.split("_").pop();
|
|
return tier ? `${label} ${tier}` : label;
|
|
}
|
|
|
|
/**
|
|
* A weekly limit scoped to one model or one surface, normalized away from whichever
|
|
* shape of the response described it.
|
|
*
|
|
* The API describes the same limit two ways during the migration: a legacy top-level
|
|
* `seven_day_<model>` key, and an entry in `limits[]`. Everything downstream works on
|
|
* this one representation so the two shapes are reconciled exactly once, in
|
|
* `reconcileScopedLimits`, rather than at each place a window is built.
|
|
*/
|
|
interface ScopedLimit {
|
|
dimension: "model" | "surface";
|
|
/** The API's own identifier. Null on every response observed so far. */
|
|
id: string | null;
|
|
/** Display name, or the id when the API sends no display name. */
|
|
name: string;
|
|
usedPct: number | null;
|
|
resetsAt: string | null;
|
|
}
|
|
|
|
// Windows that describe no particular model or surface.
|
|
const UNSCOPED_WINDOWS: ReadonlyArray<{
|
|
field: "five_hour" | "seven_day";
|
|
id: string;
|
|
label: string;
|
|
shortLabel: string;
|
|
summary: boolean;
|
|
}> = [
|
|
{ field: "five_hour", id: "five_hour", label: "Session", shortLabel: "5h", summary: true },
|
|
{ field: "seven_day", id: "weekly", label: "Weekly", shortLabel: "wk", summary: true },
|
|
];
|
|
|
|
// Scoped windows from before `limits[]` existed. Declaring the dimension here is what
|
|
// stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model*
|
|
// window: these keys are model-scoped by definition.
|
|
const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{
|
|
field: "seven_day_opus" | "seven_day_omelette";
|
|
name: string;
|
|
}> = [
|
|
{ field: "seven_day_opus", name: "Opus" },
|
|
{ field: "seven_day_omelette", name: "Omelette" },
|
|
];
|
|
|
|
/** Fold a name down to the characters an id is allowed to carry. */
|
|
function normalizeName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Whether two descriptions denote the same limit. This is the single definition of
|
|
* identity for scoped limits; nothing else may compare them, and in particular nothing
|
|
* may compare display labels, which are presentation rather than identity.
|
|
*
|
|
* - Different dimensions are never the same limit, so a surface and a model sharing a
|
|
* name stay apart.
|
|
* - When both sides carry the API's own id, that id decides, so `fable-pro` and
|
|
* `fable_pro` stay apart.
|
|
* - Otherwise fall back to the normalized name, which is the only link available between
|
|
* a legacy key (never has an id) and its `limits[]` counterpart.
|
|
*/
|
|
function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean {
|
|
if (a.dimension !== b.dimension) return false;
|
|
if (a.id && b.id) return a.id === b.id;
|
|
return normalizeName(a.name) === normalizeName(b.name);
|
|
}
|
|
|
|
/**
|
|
* Merge the legacy and `limits[]` descriptions into one limit per identity.
|
|
*
|
|
* A `limits[]` entry wins on identity because that is the representation the API is
|
|
* migrating towards, so a limit keeps the same window id whichever shape carried it.
|
|
* Its values are nullable though, so each field falls back to the legacy twin instead of
|
|
* discarding a number the response did contain.
|
|
*/
|
|
function reconcileScopedLimits(
|
|
legacy: ScopedLimit[],
|
|
fromLimitsArray: ScopedLimit[],
|
|
): ScopedLimit[] {
|
|
const reconciled = [...legacy];
|
|
for (const limit of fromLimitsArray) {
|
|
const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit));
|
|
if (index === -1) {
|
|
reconciled.push(limit);
|
|
continue;
|
|
}
|
|
const twin = reconciled[index];
|
|
reconciled[index] = {
|
|
...limit,
|
|
usedPct: limit.usedPct ?? twin?.usedPct ?? null,
|
|
resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null,
|
|
};
|
|
}
|
|
return reconciled;
|
|
}
|
|
|
|
function scopedLimitFromLegacy(
|
|
spec: (typeof LEGACY_SCOPED_WINDOWS)[number],
|
|
window: z.infer<typeof ClaudeUsageWindowSchema>,
|
|
): ScopedLimit {
|
|
return {
|
|
dimension: "model",
|
|
id: null,
|
|
name: spec.name,
|
|
usedPct: window.utilization,
|
|
resetsAt: window.resets_at ?? null,
|
|
};
|
|
}
|
|
|
|
/** The scope of a `limits[]` entry, or null when it names nothing renderable. */
|
|
function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null {
|
|
for (const dimension of ["model", "surface"] as const) {
|
|
const entry = limit.scope?.[dimension];
|
|
const id = entry?.id?.trim() || null;
|
|
const name = entry?.display_name?.trim() || id;
|
|
if (name) {
|
|
return {
|
|
dimension,
|
|
id,
|
|
name,
|
|
usedPct: limit.percent ?? null,
|
|
resetsAt: limit.resets_at ?? null,
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The client uses window ids as React keys, so they must be stable across refreshes and
|
|
// unique within a response. An API-supplied id is already an identifier and is used
|
|
// verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`);
|
|
// only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and
|
|
// `fable_pro` into one window.
|
|
function scopedWindowId(limit: ScopedLimit): string {
|
|
return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`;
|
|
}
|
|
|
|
// Backstop for the one residual case identity cannot rule out: an entry whose verbatim id
|
|
// equals another entry's normalized name. Suffix rather than drop, because a missing bar
|
|
// is the bug this change exists to fix.
|
|
function uniqueWindowId(candidate: string, taken: Set<string>): string {
|
|
if (!taken.has(candidate)) return candidate;
|
|
for (let suffix = 2; ; suffix += 1) {
|
|
const next = `${candidate}_${suffix}`;
|
|
if (!taken.has(next)) return next;
|
|
}
|
|
}
|
|
|
|
function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] {
|
|
const limits: ScopedLimit[] = [];
|
|
for (const spec of LEGACY_SCOPED_WINDOWS) {
|
|
const window = resp[spec.field];
|
|
if (window) limits.push(scopedLimitFromLegacy(spec, window));
|
|
}
|
|
return limits;
|
|
}
|
|
|
|
function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] {
|
|
const windows: UsageWindow[] = [];
|
|
for (const spec of UNSCOPED_WINDOWS) {
|
|
const window = resp[spec.field];
|
|
if (!window) continue;
|
|
windows.push(
|
|
windowFromUsedPct({
|
|
id: spec.id,
|
|
label: spec.label,
|
|
shortLabel: spec.shortLabel,
|
|
summary: spec.summary,
|
|
utilizationPct: window.utilization,
|
|
resetsAt: window.resets_at ?? null,
|
|
tone: toneFromUsedPct(window.utilization),
|
|
}),
|
|
);
|
|
}
|
|
return windows;
|
|
}
|
|
|
|
function scopedWindows(limits: ScopedLimit[]): UsageWindow[] {
|
|
const taken = new Set<string>();
|
|
return limits.map((limit) => {
|
|
const id = uniqueWindowId(scopedWindowId(limit), taken);
|
|
taken.add(id);
|
|
// Emitted even at 0% and inactive: a zero bar answers "how much of this model have I
|
|
// used", and the bar must not come and go between refreshes.
|
|
return windowFromUsedPct({
|
|
id,
|
|
label: `Weekly \u00b7 ${limit.name}`,
|
|
shortLabel: `wk ${limit.name}`,
|
|
utilizationPct: limit.usedPct,
|
|
resetsAt: limit.resetsAt,
|
|
tone: toneFromUsedPct(limit.usedPct),
|
|
});
|
|
});
|
|
}
|
|
|
|
type ClaudeKeychainCommandRunner = (args: string[]) => Promise<string | null>;
|
|
|
|
// Keep this in sync with Claude Code's Keychain account derivation.
|
|
const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
const CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT = "claude-code-user";
|
|
|
|
export function claudeKeychainAccount(
|
|
user: string = process.env["USER"] || userInfo().username,
|
|
): string {
|
|
return CLAUDE_KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT;
|
|
}
|
|
|
|
async function runSecurityCommand(args: string[]): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execFileAsync("security", args, {
|
|
timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS,
|
|
});
|
|
return stdout.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Read Claude Code's account-specific Keychain item, then try the legacy lookup. */
|
|
export async function readClaudeKeychainCredentials(
|
|
run: ClaudeKeychainCommandRunner = runSecurityCommand,
|
|
account: string = claudeKeychainAccount(),
|
|
): Promise<unknown | null> {
|
|
const lookups = [
|
|
["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
|
["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
|
];
|
|
|
|
for (const args of lookups) {
|
|
const raw = await run(args);
|
|
if (!raw) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const creds = ClaudeCredentialsSchema.safeParse(parsed);
|
|
if (creds.success && creds.data.claudeAiOauth?.accessToken) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface ClaudeCredentialLookup {
|
|
platform?: NodeJS.Platform;
|
|
readKeychainCredentials?: () => Promise<unknown | null>;
|
|
claudeHome?: string;
|
|
accountHome?: string;
|
|
}
|
|
|
|
/** Shared credential lookup for usage fetches and account identification. */
|
|
export async function resolveClaudeCredentials(
|
|
_input: UsageInput,
|
|
lookup: ClaudeCredentialLookup = {},
|
|
): Promise<ClaudeCredentialRecord | null> {
|
|
const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude");
|
|
const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json"));
|
|
if (fileCredentials) return fileCredentials;
|
|
if ((lookup.platform ?? process.platform) !== "darwin") return null;
|
|
const parsed = ClaudeCredentialsSchema.safeParse(
|
|
await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(),
|
|
);
|
|
return parsed.success ? toCredentialRecord(parsed.data) : null;
|
|
}
|
|
|
|
async function readCredentialFile(path: string): Promise<ClaudeCredentialRecord | null> {
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
return toCredentialRecord(
|
|
ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null {
|
|
const oauth = credentials.claudeAiOauth;
|
|
return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null;
|
|
}
|
|
|
|
export async function fetchUsage(
|
|
input: UsageInput,
|
|
fetchApi: typeof fetch = fetch,
|
|
credentialLookup: ClaudeCredentialLookup = {},
|
|
): Promise<UsageReport> {
|
|
/**
|
|
* Scoped limits carried by `limits[]`.
|
|
*
|
|
* Entries are validated one at a time so a single malformed or newly-shaped entry
|
|
* cannot fail the whole response and take the windows that already parsed with it.
|
|
*/
|
|
function scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] {
|
|
if (!limits) return [];
|
|
|
|
const parsed: ScopedLimit[] = [];
|
|
for (const entry of limits) {
|
|
const result = ClaudeLimitSchema.safeParse(entry);
|
|
if (!result.success) {
|
|
console.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry");
|
|
continue;
|
|
}
|
|
if (result.data.kind !== SCOPED_WEEKLY_KIND) continue;
|
|
|
|
const limit = scopedLimitFromEntry(result.data);
|
|
if (!limit) {
|
|
console.warn("Skipping scoped Claude usage limit with no resolvable scope name");
|
|
continue;
|
|
}
|
|
parsed.push(limit);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
|
const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", {
|
|
signal: AbortSignal.timeout(15_000),
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/json",
|
|
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
|
},
|
|
});
|
|
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
|
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
|
|
return ClaudeUsageResponseSchema.parse(await res.json());
|
|
}
|
|
|
|
const credentials = await resolveClaudeCredentials(input, credentialLookup);
|
|
if (!credentials) {
|
|
return unavailableUsage();
|
|
}
|
|
|
|
const { oauth } = credentials;
|
|
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
|
const resp = await callClaudeApi(oauth.accessToken);
|
|
|
|
if (resp === "NEEDS_AUTH") {
|
|
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
|
|
return unavailableUsage();
|
|
}
|
|
|
|
const scoped = reconcileScopedLimits(
|
|
legacyScopedLimits(resp),
|
|
scopedLimitsFromResponse(resp.limits),
|
|
);
|
|
const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)];
|
|
|
|
if (windows.length === 0) {
|
|
// The response parsed but described nothing. That silence is how the previous
|
|
// shape change went unnoticed, so make it greppable. `warn` and not `debug`
|
|
// because file logging defaults to `info`.
|
|
console.warn("Claude usage response parsed but produced no windows");
|
|
}
|
|
|
|
const details: UsageDetail[] = [];
|
|
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
|
if (extraUsageEnabled !== undefined) {
|
|
details.push({
|
|
id: "extra_usage",
|
|
label: "Extra usage",
|
|
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
|
});
|
|
}
|
|
|
|
return {
|
|
status: "available",
|
|
planLabel: plan ?? undefined,
|
|
windows,
|
|
balances: [],
|
|
details,
|
|
};
|
|
}
|
|
|
|
// The OAuth usage endpoint meters the active organization selected by the token.
|
|
const ClaudeAccountSchema = z.object({
|
|
accountUuid: z.string().min(1),
|
|
organizationUuid: z.string().min(1),
|
|
emailAddress: z.string().optional(),
|
|
});
|
|
type ClaudeIdentity = { key: string; label?: string } | null;
|
|
const PROFILE_TTL_MS = 300_000;
|
|
const PROFILE_CACHE_LIMIT = 128;
|
|
const profileCache = new Map<string, { at: number; result: Promise<ClaudeIdentity> }>();
|
|
|
|
function pruneProfileCache(now: number): void {
|
|
for (const [key, entry] of profileCache) {
|
|
if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key);
|
|
}
|
|
}
|
|
|
|
export async function identify(
|
|
input: UsageInput,
|
|
fetchApi: typeof fetch = fetch,
|
|
now: () => number = Date.now,
|
|
credentialLookup: ClaudeCredentialLookup = {},
|
|
) {
|
|
const directory = credentialLookup.accountHome ?? homedir();
|
|
try {
|
|
const config = z
|
|
.object({ oauthAccount: ClaudeAccountSchema })
|
|
.parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8")));
|
|
const account = config.oauthAccount;
|
|
return {
|
|
key: `${account.accountUuid}.${account.organizationUuid}`,
|
|
...(account.emailAddress ? { label: account.emailAddress } : {}),
|
|
};
|
|
} catch {
|
|
// Credentials may still be present even when account metadata is absent.
|
|
}
|
|
const credentials = await resolveClaudeCredentials(input, credentialLookup);
|
|
if (!credentials) return null;
|
|
const token = credentials.oauth.accessToken;
|
|
const tokenHash = hashAccountKey(token);
|
|
pruneProfileCache(now());
|
|
const pending = profileCache.get(tokenHash);
|
|
if (pending) return pending.result;
|
|
while (profileCache.size >= PROFILE_CACHE_LIMIT) {
|
|
const oldest = profileCache.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
profileCache.delete(oldest);
|
|
}
|
|
const request = (async () => {
|
|
const response = await fetchApi("https://api.anthropic.com/api/oauth/profile", {
|
|
signal: AbortSignal.timeout(15_000),
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/json",
|
|
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
|
},
|
|
});
|
|
if (response.status === 401 || response.status === 403) return null;
|
|
if (!response.ok) throw new Error(`Claude profile API returned ${response.status}`);
|
|
const profile = z
|
|
.object({
|
|
account: z.object({ uuid: z.string().min(1), email: z.string().optional() }),
|
|
organization: z.object({ uuid: z.string().min(1) }),
|
|
})
|
|
.parse(await response.json());
|
|
const account = profile.account;
|
|
return {
|
|
key: `${account.uuid}.${profile.organization.uuid}`,
|
|
...(account.email ? { label: account.email } : {}),
|
|
};
|
|
})();
|
|
profileCache.set(tokenHash, { at: now(), result: request });
|
|
try {
|
|
return await request;
|
|
} catch (error) {
|
|
if (profileCache.get(tokenHash)?.result === request) profileCache.delete(tokenHash);
|
|
throw error;
|
|
}
|
|
}
|