fix(auth): Long-lived OAuth authentication with multi-profile usage display (#1443)

* fix(auth): use CLAUDE_CONFIG_DIR instead of cached OAuth tokens

Stop caching OAuth tokens in profiles and always use CLAUDE_CONFIG_DIR
to let Claude CLI read fresh tokens from Keychain. This fixes 401 errors
that occurred after 8-12 hours when cached tokens expired.

Root cause: AutoClaude was storing OAuth access tokens in profiles and
using CLAUDE_CODE_OAUTH_TOKEN env var. These tokens expire in 8-12 hours
but we assumed 1-year validity. Meanwhile, Claude CLI's auto-refresh
mechanism updates Keychain tokens properly, but we weren't benefiting.

Solution:
- Remove setProfileToken() calls that cached tokens after authentication
- Update getProfileEnv() to always return CLAUDE_CONFIG_DIR for non-default profiles
- Update getActiveProfileEnv() to never fall back to cached oauthToken
- Auto-create configDir for profiles that don't have one
- Add deprecation notice to hasValidToken() for backwards compat

Now Claude CLI reads fresh tokens from Keychain on each invocation,
benefiting from its built-in token refresh mechanism.

See: docs/LONG_LIVED_AUTH_PLAN.md for full investigation details.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(auth): resolve long-lived authentication issues with Claude OAuth

The root cause was that AutoClaude cached OAuth tokens (which expire in
8-12 hours) instead of letting Claude CLI read fresh tokens from Keychain.

Changes:
- UsageMonitor now reads fresh tokens from Keychain via getCredentialsFromKeychain()
- Added anthropic-beta: oauth-2025-04-20 header required for OAuth API calls
- Fixed normalizeAnthropicResponse() to handle actual nested API format:
  { "five_hour": { "utilization": 19 } } instead of { "five_hour_utilization": 0.19 }
- Profile migration removes stale cached oauthToken values on load
- Added debug logging for keychain cache hits with token hashes
- Clear keychain cache on 401 authentication failures for quick recovery

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat(usage-indicator): improve UX with click-to-pin popup and email display

- Replace Tooltip with Popover for persistent click-to-pin behavior
  - Clicking the badge opens popup, clicking outside dismisses
  - Standard dropdown UX pattern
- Add email display under profile name in Active Account section
  - Email is fetched from keychain credentials
  - Displayed in smaller text below profile name
- Add click-to-navigate on Active Account section
  - Clicking navigates to Settings > Integrations tab
  - Provides quick access to profile management
- Add profileEmail field to ClaudeUsageSnapshot type
- Pass email through UsageMonitor fetch chain

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(usage-indicator): restore hover behavior while adding click-to-pin

Previous commit accidentally removed hover functionality when adding
click-to-pin. Users wanted BOTH behaviors:
- Hover: Show popup on hover, auto-close on mouse leave
- Click: Pin popup open until clicking outside or clicking badge again

Implemented with isPinned state to distinguish between hover-opened
and click-pinned states, with timeout-based delays for smooth UX.
Also fixed settings navigation with proper event bubbling.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(usage-monitor): fix failing tests and settings navigation

- Add keychain-utils mock to prevent tests from reading real Keychain
- Add backward compatibility for legacy Anthropic response format
  (0.72 float → 72 integer conversion)
- Fix UsageIndicator settings navigation event name
  (open-settings → open-app-settings)

All 9 previously failing tests now pass.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat(usage-indicator): add multi-profile usage display with quick swap

- Show real usage data for all Claude profiles, not just active one
- Display dual session|weekly percentages in badge with independent colors
- Add "Swap" button for instant profile switching from usage dropdown
- Use optimistic UI updates for fluid swap experience
- Extract color threshold constants for consistency (95/91/71)
- Add empty profile name fallback

Fetches inactive profile usage via their keychain credentials.
Swap immediately updates UI, then syncs with backend.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor(IntegrationSettings): remove check interval settings UI

- Removed the check interval settings UI for proactive swap feature in IntegrationSettings component.
- Simplified the component structure by eliminating unnecessary elements related to usage check interval.
- Maintained existing functionality for session threshold settings.

This change streamlines the settings interface, focusing on essential configurations while enhancing user experience.

* fix(profiles): correct keychain lookup and email extraction for OAuth profiles

- Fix keychain service name mismatch for default profiles by always using
  configDir path instead of undefined (fixes wrong usage data display)
- Add ANSI escape code stripping to email extraction to prevent truncated
  emails from terminal color codes breaking regex matching
- Always update profile email on re-authentication instead of only when missing
- Add account priority management UI with drag-and-drop reordering
- Add AccountSettings component for profile management in settings
- Clean up debug logging and optimize IntegrationSettings component

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(profiles): fix email truncation from ANSI codes and remove debug logging

- Enhanced stripAnsi() to handle OSC 8 hyperlink sequences that were
  corrupting email extraction from terminal output
- Added getEmailFromConfigDir() to read email from Claude's config file
  as authoritative source
- Added one-time migration to fix existing corrupted profile emails
- Removed temporary file-based debug-logger, keeping only console.warn
  logging that runs in debug mode (DEBUG=true)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor(usage-monitor): implement HTTP error type guard and improve error handling

- Added a type guard function `isHttpError` to check for errors with HTTP status codes.
- Updated error handling in `UsageMonitor` to utilize the new type guard for better clarity and safety.
- Enhanced the `UsageIndicator` component to revert to previous state on profile swap failure.
- Improved error logging in `AccountSettings` to provide more context on loading failures.

These changes enhance error management and improve the robustness of the application.

* feat(credentials): add cross-platform credential retrieval for macOS, Linux, and Windows

Replace macOS-only keychain-utils.ts with cross-platform credential-utils.ts that supports:
- macOS: Keychain via `security` command (existing)
- Linux: .credentials.json file in config directory
- Windows: Windows Credential Manager via PowerShell

Changes:
- Add credential-utils.ts with platform-specific implementations
- Add comprehensive tests (32 test cases) for all platforms
- Fix error cache TTL bug (errors now properly cache for 10 seconds)
- Add timeout constants for better maintainability
- Update all imports from keychain-utils to credential-utils
- Delete deprecated keychain-utils.ts

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(security): resolve CodeQL security alerts for credential handling

- Replace SHA-256 token hashing with safe fingerprint display for debug logs
  (shows first 8 + last 4 chars instead of hash to avoid CodeQL password hash warning)
- Add domain allowlist validation for usage API fetch requests
  (only allows api.anthropic.com, api.z.ai, open.bigmodel.cn)
- Remove unused afterEach import from credential-utils.test.ts

Fixes 6 high severity, 1 medium severity, and 1 note from CodeQL scan.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: resolve PR review findings for code quality and accessibility

- Replace alert() with toast() for consistent UX (3 locations)
- Add accessibility attributes to range inputs (id, htmlFor, aria-describedby)
- Remove dead code: unused profilesFile.activeProfileId assignment
- Consolidate duplicate getProfileEnv by delegating to profile manager
- Refactor getAllProfilesUsage to fetch inactive profiles in parallel
- Replace inline require('os') with top-level import

Addresses 6 of 9 PR review findings (2 medium, 4 low priority).
Remaining: Large component refactoring (separate PR), acceptable patterns.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(tests): update mocks to include getActiveProfileEnv and getProfileEnv

After refactoring getProfileEnv to delegate to profile manager,
the test mocks needed to include the new methods:
- getActiveProfileEnv() for active profile env vars
- getProfileEnv(profileId) for specific profile env vars

Updated mocks in:
- long-lived-auth.test.ts
- subprocess-spawn.test.ts

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: resolve PR review findings for race condition and dead code

- Fix race condition in getAllProfilesUsage() by batching profile updates
  after all parallel fetches complete (single save instead of concurrent saves)
- Add batchUpdateProfileUsageFromAPI() method to profile manager for atomic updates
- Remove dead IntegrationSettings component and its test file (never imported)
- Add defense-in-depth validation for credential target names (PowerShell)
- Add defense-in-depth validation for credentials paths (Linux)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: make credential path validation cross-platform compatible

Remove absolute path requirement from isValidCredentialsPath() as path.join
produces different formats on Unix vs Windows. The path traversal check
(rejecting '..') provides sufficient defense-in-depth protection.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Andy
2026-02-09 12:31:24 +02:00
committed by StillKnotKnown
co-authored by Claude Opus 4.5
parent f23d528c20
commit 2f2790b100
25 changed files with 1371 additions and 3013 deletions
@@ -95,10 +95,6 @@ export class ClaudeProfileManager {
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
// Populate missing subscription metadata for existing profiles
// This reads subscriptionType and rateLimitTier from Keychain credentials
this.populateSubscriptionMetadata();
this.initialized = true;
}
@@ -117,6 +113,8 @@ export class ClaudeProfileManager {
continue;
}
// Import dynamically to avoid circular dependency issues
const { getEmailFromConfigDir } = require('./claude-profile/profile-utils');
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail && profile.email !== configEmail) {
@@ -136,58 +134,6 @@ export class ClaudeProfileManager {
}
}
/**
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
*
* This reads from Keychain credentials and updates profiles that don't have this metadata.
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
*/
private populateSubscriptionMetadata(): void {
let needsSave = false;
for (const profile of this.data.profiles) {
if (!profile.configDir) {
continue;
}
// Skip if profile already has subscription metadata
if (profile.subscriptionType && profile.rateLimitTier) {
continue;
}
// Expand ~ to home directory
const expandedConfigDir = normalizeWindowsPath(
profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir
);
// Use helper with onlyIfMissing option to preserve existing values
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
if (result.subscriptionTypeUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
profileId: profile.id,
subscriptionType: result.subscriptionType
});
}
if (result.rateLimitTierUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
profileId: profile.id,
rateLimitTier: result.rateLimitTier
});
}
}
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
}
}
/**
* Check if the profile manager has been initialized
*/
@@ -195,6 +141,31 @@ export class ClaudeProfileManager {
return this.initialized;
}
/**
* Load profiles from disk
*/
private load(): ProfileStoreData {
const loadedData = loadProfileStore(this.storePath);
if (loadedData) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Loaded profiles:', {
count: loadedData.profiles.length,
activeProfileId: loadedData.activeProfileId,
profiles: loadedData.profiles.map(p => ({
id: p.id,
name: p.name,
email: p.email,
isDefault: p.isDefault
}))
});
}
return loadedData;
}
// Return default with a single "Default" profile
return this.createDefaultData();
}
/**
* Create default profile data
*
@@ -524,8 +495,14 @@ export class ClaudeProfileManager {
const profile = this.getActiveProfile();
const env: Record<string, string> = {};
// All profiles now use explicit CLAUDE_CONFIG_DIR for isolation
// This prevents interference with external Claude Code CLI usage
// Default profile: Claude CLI uses ~/.claude implicitly (no env var needed)
if (profile?.isDefault) {
console.warn('[ClaudeProfileManager] Using default profile (Claude CLI uses ~/.claude)');
return env;
}
// Non-default profiles: set CLAUDE_CONFIG_DIR to point Claude CLI to profile's config
// Claude CLI will read fresh tokens from Keychain, benefiting from auto-refresh
if (profile?.configDir) {
// Expand ~ to home directory for the environment variable
const expandedConfigDir = normalizeWindowsPath(
@@ -535,9 +512,7 @@ export class ClaudeProfileManager {
);
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
}
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
} else {
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
}
@@ -34,7 +34,6 @@ import {
getKeychainServiceName,
getWindowsCredentialTarget,
getCredentialsFromKeychain,
getFullCredentialsFromKeychain,
getCredentials,
clearKeychainCache,
clearCredentialCache,
@@ -211,37 +210,8 @@ describe('credential-utils', () => {
vi.mocked(homedir).mockReturnValue('/home/testuser');
});
// Helper to mock Secret Service not available (secret-tool not found)
const mockSecretServiceUnavailable = () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = String(path);
// secret-tool not found
if (pathStr.includes('secret-tool')) return false;
// credentials file exists
if (pathStr.includes('.credentials.json')) return true;
return false;
});
};
it('should return credentials from Secret Service when available', () => {
// secret-tool exists and returns credentials
it('should return credentials from .credentials.json', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-secret-service-token',
email: '[email protected]',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-secret-service-token');
expect(result.email).toBe('[email protected]');
expect(result.error).toBeUndefined();
});
it('should fall back to .credentials.json when Secret Service unavailable', () => {
mockSecretServiceUnavailable();
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-linux-token-456',
@@ -256,7 +226,7 @@ describe('credential-utils', () => {
expect(result.error).toBeUndefined();
});
it('should return null when credentials file not found and Secret Service unavailable', () => {
it('should return null when credentials file not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
@@ -267,12 +237,7 @@ describe('credential-utils', () => {
it('should use custom configDir for credentials path', () => {
const customConfigDir = '/home/user/.claude-profiles/work';
mockSecretServiceUnavailable();
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = String(path);
if (pathStr.includes('secret-tool')) return false;
return true; // credentials file exists
});
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-custom-token',
@@ -287,7 +252,7 @@ describe('credential-utils', () => {
});
it('should handle emailAddress field (alternative email location)', () => {
mockSecretServiceUnavailable();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token',
@@ -301,7 +266,7 @@ describe('credential-utils', () => {
});
it('should handle top-level email field', () => {
mockSecretServiceUnavailable();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-token',
@@ -315,7 +280,7 @@ describe('credential-utils', () => {
});
it('should handle file read permission errors', () => {
mockSecretServiceUnavailable();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error('EACCES: permission denied');
});
@@ -325,30 +290,6 @@ describe('credential-utils', () => {
expect(result.token).toBeNull();
expect(result.email).toBeNull();
});
it('should fall back to file when Secret Service lookup fails', () => {
// secret-tool exists but lookup fails
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = String(path);
if (pathStr.includes('secret-tool')) return true;
if (pathStr.includes('.credentials.json')) return true;
return false;
});
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('secret-tool lookup failed');
});
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-fallback-token',
email: '[email protected]',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-fallback-token');
expect(result.email).toBe('[email protected]');
});
});
describe('getCredentialsFromKeychain (Windows)', () => {
@@ -359,24 +300,19 @@ describe('credential-utils', () => {
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
});
it('should return null when PowerShell not found and no credentials file exists', () => {
// Neither PowerShell nor credentials file exists
it('should return null when PowerShell not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
// No error because file fallback returns null gracefully when file doesn't exist
expect(result.error).toBe('PowerShell not found');
});
it('should return credentials from Windows Credential Manager when file is empty', () => {
// Mock PowerShell path found, but credentials file doesn't exist
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
// PowerShell exists, but credentials file doesn't
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
it('should return credentials from Windows Credential Manager', () => {
// Mock PowerShell path found
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-windows-token-789',
@@ -390,33 +326,9 @@ describe('credential-utils', () => {
expect(result.email).toBe('[email protected]');
});
it('should fall back to file when Credential Manager returns empty', () => {
// Mock PowerShell exists but returns empty (no credential in Credential Manager)
// Mock file exists with valid credentials
it('should return null when credential not found', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-fallback-token',
email: '[email protected]',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-file-fallback-token');
expect(result.email).toBe('[email protected]');
});
it('should return null when both Credential Manager and file have no credentials', () => {
// Mock PowerShell exists but returns empty
// Mock credentials file doesn't exist
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
// PowerShell exists, but credentials file doesn't
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
vi.mocked(execFileSync).mockReturnValue('');
const result = getCredentialsFromKeychain();
@@ -424,137 +336,14 @@ describe('credential-utils', () => {
expect(result.email).toBeNull();
});
it('should handle invalid JSON from Credential Manager by falling back to file', () => {
it('should handle invalid JSON from Credential Manager', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue('invalid json'); // Invalid JSON from Credential Manager
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-token-after-cm-failure',
email: '[email protected]',
},
}));
vi.mocked(execFileSync).mockReturnValue('invalid json');
const result = getCredentialsFromKeychain();
// Should fall back to file and get valid credentials
expect(result.token).toBe('sk-ant-file-token-after-cm-failure');
expect(result.email).toBe('[email protected]');
});
it('should prefer file credentials when both sources have tokens', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-windows-file-token',
email: '[email protected]',
},
}));
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-token',
email: '[email protected]',
},
}));
const result = getCredentialsFromKeychain();
// Should prefer file since Claude CLI writes there after login
expect(result.token).toBe('sk-ant-windows-file-token');
expect(result.email).toBe('[email protected]');
});
});
describe('getFullCredentialsFromKeychain (Windows)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(true);
vi.mocked(isLinux).mockReturnValue(false);
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
clearCredentialCache();
});
it('should return full credentials from file when available', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-full-creds-token',
refreshToken: 'refresh-token-123',
expiresAt: 1700000000000,
email: '[email protected]',
scopes: ['user:read', 'user:write'],
},
}));
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
const result = getFullCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-full-creds-token');
expect(result.refreshToken).toBe('refresh-token-123');
expect(result.expiresAt).toBe(1700000000000);
expect(result.email).toBe('[email protected]');
expect(result.scopes).toEqual(['user:read', 'user:write']);
});
it('should return credentials from Credential Manager when file is empty', () => {
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-full-token',
refreshToken: 'credman-refresh',
expiresAt: 1700000000000,
email: '[email protected]',
},
}));
const result = getFullCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-credman-full-token');
expect(result.refreshToken).toBe('credman-refresh');
expect(result.email).toBe('[email protected]');
});
it('should prefer file credentials when both sources have tokens (consistent with basic API)', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-full-token',
refreshToken: 'file-refresh',
expiresAt: 1700000000000,
email: '[email protected]',
},
}));
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-full-token',
refreshToken: 'credman-refresh',
expiresAt: 1800000000000, // Later expiry
email: '[email protected]',
},
}));
const result = getFullCredentialsFromKeychain();
// Should prefer file since Claude CLI writes there after login
// This is consistent with getCredentialsFromKeychain behavior
expect(result.token).toBe('sk-ant-file-full-token');
expect(result.refreshToken).toBe('file-refresh');
expect(result.email).toBe('[email protected]');
});
it('should return null when both sources have no credentials', () => {
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue('');
const result = getFullCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.refreshToken).toBeNull();
expect(result.email).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -40,94 +40,6 @@ export interface ProfileStoreData {
autoSwitch?: ClaudeAutoSwitchSettings;
/** Unified priority order for both OAuth and API profiles */
accountPriorityOrder?: string[];
/**
* Profile IDs that were migrated from shared ~/.claude to isolated directories.
* These profiles need re-authentication since their credentials are in the old location.
* Cleared after successful re-authentication.
*/
migratedProfileIds?: string[];
}
/**
* Check if a profile uses the legacy shared ~/.claude directory
*/
function usesLegacySharedDirectory(profile: ClaudeProfile): boolean {
if (!profile.configDir) return false;
// Normalize paths for comparison
const normalizedConfigDir = profile.configDir.startsWith('~')
? join(homedir(), profile.configDir.slice(1))
: profile.configDir;
return normalizedConfigDir === DEFAULT_CLAUDE_CONFIG_DIR;
}
/**
* Migrate a profile from shared ~/.claude to isolated ~/.claude-profiles/{name}
* Returns the new configDir path
*
* Handles directory collisions by appending a counter (e.g., 'work-account-2')
* when two profile names sanitize to the same value.
*/
function migrateProfileToIsolatedDirectory(profile: ClaudeProfile): string {
// Generate isolated directory name from profile name
const baseName = profile.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'primary';
// Ensure the profiles directory exists
if (!existsSync(CLAUDE_PROFILES_DIR)) {
mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true });
}
// Check for directory collision and append counter if needed
let sanitizedName = baseName;
let counter = 1;
let isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
// Keep incrementing counter until we find an available directory name
// Use profile.id as a marker file to detect if the directory belongs to this profile
// NOTE: There's a TOCTOU race window between existsSync and readFileSync, but this is
// acceptable because profile directory creation is infrequent and concurrent creation
// is unlikely. The worst case is we increment the counter unnecessarily.
while (existsSync(isolatedDir)) {
const markerFile = join(isolatedDir, '.profile-id');
if (existsSync(markerFile)) {
try {
const existingId = readFileSync(markerFile, 'utf-8').trim();
if (existingId === profile.id) {
// This directory belongs to us, use it
break;
}
} catch {
// Ignore read errors, treat as collision
}
}
// Directory exists but belongs to different profile, try next counter
counter++;
sanitizedName = `${baseName}-${counter}`;
isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
}
// Create the profile directory if it doesn't exist
if (!existsSync(isolatedDir)) {
mkdirSync(isolatedDir, { recursive: true });
}
// Write a marker file with our profile ID for collision detection
// Use 'wx' flag to atomically create file only if it doesn't exist (avoids TOCTOU race)
const markerFile = join(isolatedDir, '.profile-id');
try {
writeFileSync(markerFile, profile.id, { encoding: 'utf-8', flag: 'wx' });
} catch (err) {
// EEXIST means file already exists, which is fine - we already own this directory
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
console.warn('[ProfileStorage] Failed to write marker file:', err);
}
}
console.warn(`[ProfileStorage] Migrated profile "${profile.name}" from ~/.claude to ${isolatedDir}`);
console.warn('[ProfileStorage] NOTE: Credentials remain at ~/.claude - user should re-authenticate in Settings > Accounts');
return isolatedDir;
}
/**
@@ -144,9 +56,6 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
}
if (data.version === STORE_VERSION) {
// Track profiles that were migrated in this session
const newlyMigratedProfileIds: string[] = [];
// Parse dates and migrate profile data
const profiles = data.profiles as ClaudeProfile[];
data.profiles = profiles.map((p: ClaudeProfile) => {
@@ -161,23 +70,8 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p;
// MIGRATION: Move profiles from shared ~/.claude to isolated directories
// This prevents interference with external Claude Code CLI usage
let configDir = profileWithoutToken.configDir;
if (usesLegacySharedDirectory(p)) {
configDir = migrateProfileToIsolatedDirectory(p);
// Track this profile as newly migrated (needs re-authentication)
newlyMigratedProfileIds.push(p.id);
console.warn('[ProfileStorage] Profile isolation migration:', {
profileName: p.name,
oldConfigDir: p.configDir,
newConfigDir: configDir
});
}
return {
...profileWithoutToken,
configDir, // Use migrated configDir
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
@@ -191,14 +85,6 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
}))
};
});
// Merge newly migrated profiles with any existing migratedProfileIds
const existingMigrated = (data.migratedProfileIds as string[] | undefined) || [];
const allMigratedIds = [...new Set([...existingMigrated, ...newlyMigratedProfileIds])];
if (allMigratedIds.length > 0) {
data.migratedProfileIds = allMigratedIds;
}
return data as unknown as ProfileStoreData;
}
@@ -203,26 +203,6 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
return true;
}
/**
* Check if an API profile has valid authentication credentials.
* Validates that both apiKey and baseUrl are present and non-empty.
*
* @param profile - The API profile to check
* @returns true if the profile has both apiKey and baseUrl, false otherwise
*/
export function isAPIProfileAuthenticated(profile: APIProfile): boolean {
// Check for presence of required fields
if (!profile?.apiKey || !profile?.baseUrl) {
return false;
}
// Validate that the fields are non-empty strings (after trimming whitespace)
const hasValidApiKey = typeof profile.apiKey === 'string' && profile.apiKey.trim().length > 0;
const hasValidBaseUrl = typeof profile.baseUrl === 'string' && profile.baseUrl.trim().length > 0;
return hasValidApiKey && hasValidBaseUrl;
}
/**
* Expand ~ in path to home directory
*/
@@ -56,6 +56,15 @@ vi.mock('../services/profile/profile-manager', () => ({
loadProfilesFile: () => mockLoadProfilesFile()
}));
// Mock credential-utils to return mock token instead of reading real credentials
vi.mock('./credential-utils', () => ({
getCredentialsFromKeychain: vi.fn(() => ({
token: 'mock-decrypted-token',
email: '[email protected]'
})),
clearKeychainCache: vi.fn()
}));
// Mock global fetch
global.fetch = vi.fn(() =>
Promise.resolve({
@@ -720,7 +729,7 @@ describe('usage-monitor', () => {
// 401 errors should throw
await expect(
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 401');
expect(consoleSpy).toHaveBeenCalled();
@@ -751,7 +760,7 @@ describe('usage-monitor', () => {
// 403 errors should throw
await expect(
monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 403');
expect(consoleSpy).toHaveBeenCalled();
@@ -771,7 +780,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -786,7 +795,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -808,7 +817,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile');
const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -831,7 +840,7 @@ describe('usage-monitor', () => {
// 401 errors should throw with proper message
await expect(
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile')
monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined)
).rejects.toThrow('API Auth Failure: 401');
expect(consoleSpy).toHaveBeenCalled();
@@ -945,7 +954,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile');
const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -977,7 +986,7 @@ describe('usage-monitor', () => {
const monitor = getUsageMonitor();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile');
const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile', undefined);
expect(usage).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
@@ -1013,6 +1022,7 @@ describe('usage-monitor', () => {
'unknown-api-key',
'unknown-profile-1',
'Unknown Profile',
undefined,
unknownProviderProfile
);
@@ -1442,7 +1452,7 @@ describe('usage-monitor', () => {
const profileId = 'test-profile-cooldown';
// Call fetchUsageViaAPI which should fail and record timestamp
await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile');
await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile', undefined);
// Verify failure timestamp was recorded
const failureTimestamp = monitor['apiFailureTimestamps'].get(profileId);
@@ -1569,6 +1579,7 @@ describe('usage-monitor', () => {
'sk-ant-api-key',
'api-profile-1',
'API Profile',
undefined,
predeterminedProfile
);
@@ -1615,6 +1626,7 @@ describe('usage-monitor', () => {
'sk-ant-api-key',
'api-profile-1',
'API Profile',
undefined, // No email
undefined // No activeProfile passed
);
@@ -1655,6 +1667,7 @@ describe('usage-monitor', () => {
'oauth-token',
'oauth-profile',
'OAuth Profile',
undefined,
oauthProfile
);
@@ -12,14 +12,40 @@
import { EventEmitter } from 'events';
import { homedir } from 'os';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { ClaudeUsageSnapshot } from '../../shared/types/agent';
import { ClaudeUsageSnapshot, ProfileUsageSummary, AllProfilesUsage } from '../../shared/types/agent';
import { loadProfilesFile } from '../services/profile/profile-manager';
import type { APIProfile } from '../../shared/types/profile';
import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../shared/utils/provider-detection';
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
import { isProfileRateLimited } from './rate-limit-manager';
// Re-export for backward compatibility
export type { ApiProvider };
/**
* Create a safe fingerprint of a credential for debug logging.
* Shows first 8 and last 4 characters, hiding the sensitive middle portion.
* This is NOT for authentication - only for human-readable debug identification.
*
* @param credential - The credential (token or API key) to create a fingerprint for
* @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no credential
*/
function getCredentialFingerprint(credential: string | null | undefined): string {
if (!credential) return 'null';
if (credential.length <= 16) return credential.slice(0, 4) + '...' + credential.slice(-2);
return credential.slice(0, 8) + '...' + credential.slice(-4);
}
/**
* Allowed domains for usage API requests.
* Only these domains are permitted for outbound usage monitoring requests.
*/
const ALLOWED_USAGE_API_DOMAINS = new Set([
'api.anthropic.com',
'api.z.ai',
'open.bigmodel.cn',
]);
/**
* Provider usage endpoint configuration
* Maps each provider to its usage monitoring endpoint path
@@ -156,11 +182,21 @@ export function detectProvider(baseUrl: string): ApiProvider {
interface ActiveProfileResult {
profileId: string;
profileName: string;
profileEmail?: string;
isAPIProfile: boolean;
baseUrl: string;
credential?: string;
}
/**
* Type guard to check if an error has an HTTP status code
* @param error - The error to check
* @returns true if the error has a statusCode property
*/
function isHttpError(error: unknown): error is Error & { statusCode?: number } {
return error instanceof Error && 'statusCode' in error;
}
export class UsageMonitor extends EventEmitter {
private static instance: UsageMonitor;
private intervalId: NodeJS.Timeout | null = null;
@@ -177,6 +213,11 @@ export class UsageMonitor extends EventEmitter {
private authFailedProfiles: Map<string, number> = new Map(); // profileId -> timestamp
private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown
// Cache for all profiles' usage data
// Map<profileId, { usage: ProfileUsageSummary, fetchedAt: number }>
private allProfilesUsageCache: Map<string, { usage: ProfileUsageSummary; fetchedAt: number }> = new Map();
private static PROFILE_USAGE_CACHE_TTL_MS = 60 * 1000; // 1 minute cache for inactive profiles
// Debug flag for verbose logging
private readonly isDebug = process.env.DEBUG === 'true';
@@ -252,13 +293,330 @@ export class UsageMonitor extends EventEmitter {
return this.currentUsage;
}
/**
* Clear the usage cache for a specific profile.
* Called after re-authentication to ensure fresh usage data is fetched.
*
* @param profileId - Profile identifier to clear cache for
*/
clearProfileUsageCache(profileId: string): void {
const deleted = this.allProfilesUsageCache.delete(profileId);
if (this.isDebug) {
console.warn('[UsageMonitor] Cleared usage cache for profile:', {
profileId,
wasInCache: deleted
});
}
}
/**
* Get all profiles usage data (for multi-profile display in UI)
* Returns cached data if fresh, otherwise fetches for all profiles
*
* Uses parallel fetching for inactive profiles to minimize blocking delays.
*/
async getAllProfilesUsage(): Promise<AllProfilesUsage | null> {
const profileManager = getClaudeProfileManager();
const settings = profileManager.getSettings();
const activeProfileId = settings.activeProfileId;
if (!this.currentUsage) {
return null;
}
const now = Date.now();
const allProfiles: ProfileUsageSummary[] = [];
// First pass: identify profiles that need fresh data vs cached
type ProfileToFetch = { profile: typeof settings.profiles[0]; index: number };
const profilesToFetch: ProfileToFetch[] = [];
const profileResults: (ProfileUsageSummary | null)[] = new Array(settings.profiles.length).fill(null);
for (let i = 0; i < settings.profiles.length; i++) {
const profile = settings.profiles[i];
const cached = this.allProfilesUsageCache.get(profile.id);
// Use cached data if fresh (within TTL)
if (cached && (now - cached.fetchedAt) < UsageMonitor.PROFILE_USAGE_CACHE_TTL_MS) {
profileResults[i] = {
...cached.usage,
isActive: profile.id === activeProfileId
};
continue;
}
// For active profile, use the current detailed usage
if (profile.id === activeProfileId && this.currentUsage) {
const summary = this.buildProfileUsageSummary(profile, this.currentUsage);
profileResults[i] = summary;
this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now });
continue;
}
// Mark for parallel fetch
profilesToFetch.push({ profile, index: i });
}
// Parallel fetch for all inactive profiles that need fresh data
if (profilesToFetch.length > 0) {
// Collect usage updates for batch save (avoids race condition with concurrent saves)
const usageUpdates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }> = [];
const fetchPromises = profilesToFetch.map(async ({ profile, index }) => {
const inactiveUsage = await this.fetchUsageForInactiveProfile(profile);
const rateLimitStatus = isProfileRateLimited(profile);
let sessionPercent = 0;
let weeklyPercent = 0;
if (inactiveUsage) {
sessionPercent = inactiveUsage.sessionPercent;
weeklyPercent = inactiveUsage.weeklyPercent;
// Collect update for batch save (don't save here to avoid race condition)
return {
index,
update: { profileId: profile.id, sessionPercent, weeklyPercent },
profile,
inactiveUsage,
rateLimitStatus
};
} else {
// Fallback to cached profile data if API fetch failed
sessionPercent = profile.usage?.sessionUsagePercent ?? 0;
weeklyPercent = profile.usage?.weeklyUsagePercent ?? 0;
return {
index,
update: null, // No update needed for fallback
profile,
inactiveUsage,
rateLimitStatus,
sessionPercent,
weeklyPercent
};
}
});
// Wait for all fetches to complete in parallel
const fetchResults = await Promise.all(fetchPromises);
// Collect all updates and build summaries
for (const result of fetchResults) {
const { index, update, profile, inactiveUsage, rateLimitStatus } = result;
// Get percentages from either the update or the fallback values
const sessionPercent = update?.sessionPercent ?? result.sessionPercent ?? 0;
const weeklyPercent = update?.weeklyPercent ?? result.weeklyPercent ?? 0;
if (update) {
usageUpdates.push(update);
}
const summary: ProfileUsageSummary = {
profileId: profile.id,
profileName: profile.name,
profileEmail: profile.email,
sessionPercent,
weeklyPercent,
isAuthenticated: profile.isAuthenticated ?? false,
isRateLimited: rateLimitStatus.limited,
rateLimitType: rateLimitStatus.type,
availabilityScore: this.calculateAvailabilityScore(
sessionPercent,
weeklyPercent,
rateLimitStatus.limited,
rateLimitStatus.type,
profile.isAuthenticated ?? false
),
isActive: profile.id === activeProfileId,
lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString()
};
this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now });
profileResults[index] = summary;
}
// Batch save all usage updates at once (single disk write, no race condition)
if (usageUpdates.length > 0) {
profileManager.batchUpdateProfileUsageFromAPI(usageUpdates);
}
}
// Collect non-null results
for (const result of profileResults) {
if (result) {
allProfiles.push(result);
}
}
// Sort by availability score (highest first = most available)
allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
return {
activeProfile: this.currentUsage,
allProfiles,
fetchedAt: new Date()
};
}
/**
* Fetch usage for an inactive profile using its own credentials
* This allows showing real usage data for non-active profiles
*/
private async fetchUsageForInactiveProfile(
profile: { id: string; name: string; email?: string; configDir?: string; isAuthenticated?: boolean }
): Promise<ClaudeUsageSnapshot | null> {
// Only fetch for authenticated profiles with a configDir
if (!profile.isAuthenticated || !profile.configDir) {
if (this.isDebug) {
console.warn('[UsageMonitor] Skipping inactive profile fetch - not authenticated or no configDir:', {
profileId: profile.id,
profileName: profile.name,
isAuthenticated: profile.isAuthenticated,
hasConfigDir: !!profile.configDir
});
}
return null;
}
try {
// Get credentials from keychain for this profile's configDir
const expandedConfigDir = profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir;
const keychainCreds = getCredentialsFromKeychain(expandedConfigDir);
if (!keychainCreds.token) {
if (this.isDebug) {
console.warn('[UsageMonitor] No keychain credentials for inactive profile:', profile.name);
}
return null;
}
if (this.isDebug) {
console.warn('[UsageMonitor] Fetching usage for inactive profile:', {
profileId: profile.id,
profileName: profile.name,
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
}
// Fetch usage via API - OAuth profiles always use Anthropic
const usage = await this.fetchUsageViaAPI(
keychainCreds.token,
profile.id,
profile.name,
keychainCreds.email ?? profile.email,
{
profileId: profile.id,
profileName: profile.name,
profileEmail: keychainCreds.email ?? profile.email,
isAPIProfile: false,
baseUrl: 'https://api.anthropic.com'
}
);
if (this.isDebug && usage) {
console.warn('[UsageMonitor] Successfully fetched inactive profile usage:', {
profileName: profile.name,
sessionPercent: usage.sessionPercent,
weeklyPercent: usage.weeklyPercent
});
}
return usage;
} catch (error) {
console.warn('[UsageMonitor] Failed to fetch inactive profile usage:', profile.name, error);
return null;
}
}
/**
* Build a ProfileUsageSummary from a ClaudeUsageSnapshot
*/
private buildProfileUsageSummary(
profile: { id: string; name: string; email?: string; isAuthenticated?: boolean },
usage: ClaudeUsageSnapshot
): ProfileUsageSummary {
const profileManager = getClaudeProfileManager();
const fullProfile = profileManager.getProfile(profile.id);
const rateLimitStatus = fullProfile ? isProfileRateLimited(fullProfile) : { limited: false };
return {
profileId: profile.id,
profileName: profile.name,
profileEmail: usage.profileEmail || profile.email,
sessionPercent: usage.sessionPercent,
weeklyPercent: usage.weeklyPercent,
sessionResetTimestamp: usage.sessionResetTimestamp,
weeklyResetTimestamp: usage.weeklyResetTimestamp,
isAuthenticated: profile.isAuthenticated ?? true,
isRateLimited: rateLimitStatus.limited,
rateLimitType: rateLimitStatus.type,
availabilityScore: this.calculateAvailabilityScore(
usage.sessionPercent,
usage.weeklyPercent,
rateLimitStatus.limited,
rateLimitStatus.type,
profile.isAuthenticated ?? true
),
isActive: usage.profileId === profileManager.getActiveProfile()?.id,
lastFetchedAt: usage.fetchedAt?.toISOString()
};
}
/**
* Calculate availability score for a profile (higher = more available)
*
* Scoring algorithm:
* - Base score: 100
* - Rate limited: -500 (session) or -1000 (weekly)
* - Unauthenticated: -500
* - Weekly usage penalty: -(weeklyPercent * 0.5)
* - Session usage penalty: -(sessionPercent * 0.2)
*/
private calculateAvailabilityScore(
sessionPercent: number,
weeklyPercent: number,
isRateLimited: boolean,
rateLimitType?: 'session' | 'weekly',
isAuthenticated: boolean = true
): number {
let score = 100;
// Penalize rate-limited profiles heavily
if (isRateLimited) {
if (rateLimitType === 'weekly') {
score -= 1000; // Weekly limit is worse (takes longer to reset)
} else {
score -= 500; // Session limit resets sooner
}
}
// Penalize unauthenticated profiles
if (!isAuthenticated) {
score -= 500;
}
// Penalize based on current usage (weekly more important)
score -= weeklyPercent * 0.5;
score -= sessionPercent * 0.2;
return Math.round(score * 100) / 100; // Round to 2 decimal places
}
/**
* Get credential for usage monitoring (OAuth token or API key)
* Detects profile type and returns appropriate credential
*
* Priority:
* 1. API Profile (if active) - returns apiKey directly
* 2. OAuth Profile - returns decrypted oauthToken
* 2. OAuth Profile - reads FRESH token from Keychain (not cached oauthToken)
*
* IMPORTANT: For OAuth profiles, we read from Keychain instead of cached profile.oauthToken.
* OAuth tokens expire in 8-12 hours, but Claude CLI auto-refreshes and stores fresh tokens
* in Keychain. Using cached tokens causes 401 errors after a few hours.
* See: docs/LONG_LIVED_AUTH_PLAN.md
*
* @returns The credential string or undefined if none available
*/
@@ -284,15 +642,32 @@ export class UsageMonitor extends EventEmitter {
}
}
// Fall back to OAuth profile
// Fall back to OAuth profile - read FRESH token from Keychain
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (activeProfile?.oauthToken) {
const decryptedToken = profileManager.getProfileToken(activeProfile.id);
if (this.isDebug && decryptedToken) {
console.warn('[UsageMonitor:TRACE] Using OAuth profile credential:', activeProfile.name);
if (activeProfile) {
// Read fresh token from Keychain using configDir (same as CLAUDE_CONFIG_DIR)
// This ensures we get the auto-refreshed token, not a stale cached copy
// IMPORTANT: Always pass configDir, even for default profiles - the keychain
// service name is based on the expanded path (e.g., /Users/xxx/.claude), not undefined
const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir);
if (keychainCreds.token) {
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Using OAuth token from Keychain for profile:', activeProfile.name, {
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
}
return keychainCreds.token;
}
// Keychain read failed - log warning (don't fall back to cached token)
if (keychainCreds.error) {
console.warn('[UsageMonitor] Keychain access failed:', keychainCreds.error);
} else if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] No token in Keychain for profile:', activeProfile.name,
'- user may need to re-authenticate with claude /login');
}
return decryptedToken;
}
// No credential available
@@ -340,9 +715,19 @@ export class UsageMonitor extends EventEmitter {
this.currentUsage = usage;
this.currentUsageProfileId = profileId; // Track which profile this usage belongs to
// Step 2.5: Persist usage to profile for caching (so other profiles can display cached usage)
const profileManager = getClaudeProfileManager();
profileManager.updateProfileUsageFromAPI(profileId, usage.sessionPercent, usage.weeklyPercent);
// Step 3: Emit usage update for UI (always emit, regardless of proactive swap settings)
this.emit('usage-updated', usage);
// Step 3.5: Emit all profiles usage for multi-profile display
const allProfilesUsage = await this.getAllProfilesUsage();
if (allProfilesUsage) {
this.emit('all-profiles-usage-updated', allProfilesUsage);
}
// Step 4: Check thresholds and perform proactive swap (OAuth profiles only)
if (!isAPIProfile) {
const profileManager = getClaudeProfileManager();
@@ -396,7 +781,7 @@ export class UsageMonitor extends EventEmitter {
}
} catch (error) {
// Step 5: Handle auth failures
if ((error as any).statusCode === 401 || (error as any).statusCode === 403) {
if (isHttpError(error) && (error.statusCode === 401 || error.statusCode === 403)) {
if (profileId) {
await this.handleAuthFailure(profileId, isAPIProfile);
return; // handleAuthFailure manages its own logging
@@ -485,19 +870,32 @@ export class UsageMonitor extends EventEmitter {
return null;
}
// Get email from profile or try keychain
let profileEmail = activeOAuthProfile.email;
if (!profileEmail) {
// Try to get email from keychain
// IMPORTANT: Always pass configDir - service name is based on expanded path (e.g., /Users/xxx/.claude)
const keychainCreds = getCredentialsFromKeychain(activeOAuthProfile.configDir);
profileEmail = keychainCreds.email ?? undefined;
}
if (this.isDebug) {
console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', {
profileId: activeOAuthProfile.id,
profileName: activeOAuthProfile.name
profileName: activeOAuthProfile.name,
profileEmail
});
}
return {
const result = {
profileId: activeOAuthProfile.id,
profileName: activeOAuthProfile.name,
profileEmail,
isAPIProfile: false,
baseUrl: 'https://api.anthropic.com'
};
return result;
}
/**
@@ -529,6 +927,17 @@ export class UsageMonitor extends EventEmitter {
*/
private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise<void> {
const profileManager = getClaudeProfileManager();
// Clear keychain cache for this profile so next attempt gets fresh credentials
// This handles cases where the token was refreshed by Claude CLI but our cache is stale
if (!isAPIProfile) {
const profile = profileManager.getProfile(profileId);
if (profile?.configDir) {
console.warn('[UsageMonitor] Auth failure - clearing keychain cache for profile:', profileId);
clearKeychainCache(profile.configDir);
}
}
const settings = profileManager.getAutoSwitchSettings();
// Proactive swap is only supported for OAuth profiles, not API profiles
@@ -578,27 +987,45 @@ export class UsageMonitor extends EventEmitter {
credential?: string,
activeProfile?: ActiveProfileResult
): Promise<ClaudeUsageSnapshot | null> {
// Get profile name - check both API profiles and OAuth profiles
// Get profile name and email - prefer activeProfile since it's already determined
let profileName: string | undefined;
let profileEmail: string | undefined;
// First, check if it's an API profile
try {
const profilesFile = await loadProfilesFile();
const apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile) {
profileName = apiProfile.name;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found API profile:', {
profileId,
profileName,
baseUrl: apiProfile.baseUrl
});
}
}
} catch (error) {
// Failed to load API profiles, continue to OAuth check
// Use activeProfile data if available (already fetched and validated)
// This fixes the bug where API profile names were incorrectly shown for OAuth profiles
if (activeProfile?.profileName) {
profileName = activeProfile.profileName;
profileEmail = activeProfile.profileEmail;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error);
console.warn('[UsageMonitor:FETCH] Using activeProfile data:', {
profileId,
profileName,
profileEmail,
isAPIProfile: activeProfile.isAPIProfile
});
}
}
// Only search API profiles if not already set from activeProfile
if (!profileName) {
try {
const profilesFile = await loadProfilesFile();
const apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile) {
profileName = apiProfile.name;
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found API profile:', {
profileId,
profileName,
baseUrl: apiProfile.baseUrl
});
}
}
} catch (error) {
// Failed to load API profiles, continue to OAuth check
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error);
}
}
}
@@ -608,10 +1035,15 @@ export class UsageMonitor extends EventEmitter {
const oauthProfile = profileManager.getProfile(profileId);
if (oauthProfile) {
profileName = oauthProfile.name;
// Get email from OAuth profile if not already set
if (!profileEmail) {
profileEmail = oauthProfile.email;
}
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Found OAuth profile:', {
profileId,
profileName
profileName,
profileEmail
});
}
}
@@ -638,7 +1070,7 @@ export class UsageMonitor extends EventEmitter {
if (this.isDebug) {
console.warn('[UsageMonitor:FETCH] Attempting API fetch method');
}
const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, activeProfile);
const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, profileEmail, activeProfile);
if (apiUsage) {
console.warn('[UsageMonitor] Successfully fetched via API');
if (this.isDebug) {
@@ -683,6 +1115,7 @@ export class UsageMonitor extends EventEmitter {
* @param credential - OAuth token or API key
* @param profileId - Profile identifier
* @param profileName - Profile display name
* @param profileEmail - Optional email associated with the profile
* @param activeProfile - Optional pre-determined active profile info to avoid race conditions
* @returns Normalized usage snapshot or null on failure
*/
@@ -690,6 +1123,7 @@ export class UsageMonitor extends EventEmitter {
credential: string,
profileId: string,
profileName: string,
profileEmail?: string,
activeProfile?: ActiveProfileResult
): Promise<ClaudeUsageSnapshot | null> {
if (this.isDebug) {
@@ -754,6 +1188,14 @@ export class UsageMonitor extends EventEmitter {
return null;
}
if (this.isDebug) {
console.warn('[UsageMonitor:API_FETCH] API request:', {
endpoint: usageEndpoint,
profileId,
credentialFingerprint: getCredentialFingerprint(credential)
});
}
if (this.isDebug) {
console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', {
provider,
@@ -762,25 +1204,45 @@ export class UsageMonitor extends EventEmitter {
});
}
// Step 4: Fetch usage from provider endpoint
// Step 4: Validate endpoint domain before making request
// Security: Only allow requests to known provider domains
let endpointHostname: string;
try {
const endpointUrl = new URL(usageEndpoint);
endpointHostname = endpointUrl.hostname;
} catch {
console.error('[UsageMonitor] Invalid usage endpoint URL:', usageEndpoint);
return null;
}
if (!ALLOWED_USAGE_API_DOMAINS.has(endpointHostname)) {
console.error('[UsageMonitor] Blocked request to unauthorized domain:', endpointHostname, {
allowedDomains: Array.from(ALLOWED_USAGE_API_DOMAINS)
});
return null;
}
// Step 5: Fetch usage from provider endpoint
// All providers use Bearer token authentication (RFC 6750)
const authHeader = `Bearer ${credential}`;
const response = await fetch(usageEndpoint, {
method: 'GET',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',
...(provider === 'anthropic' && { 'anthropic-version': '2023-06-01' })
}
// Build headers based on provider
// Anthropic OAuth requires the 'anthropic-beta: oauth-2025-04-20' header
// See: https://codelynx.dev/posts/claude-code-usage-limits-statusline
const headers: Record<string, string> = {
'Authorization': authHeader,
'Content-Type': 'application/json',
};
if (provider === 'anthropic') {
// OAuth authentication requires the beta header
headers['anthropic-beta'] = 'oauth-2025-04-20';
headers['anthropic-version'] = '2023-06-01';
}
const isAPIProfile = !!apiProfile;
this.debugLog('[UsageMonitor:TRACE] Fetching usage', {
provider,
baseUrl,
isAPIProfile,
profileId
const response = await fetch(usageEndpoint, {
method: 'GET',
headers
});
if (!response.ok) {
@@ -900,13 +1362,13 @@ export class UsageMonitor extends EventEmitter {
switch (provider) {
case 'anthropic':
normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName);
normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName, profileEmail);
break;
case 'zai':
normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName);
normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName, profileEmail);
break;
case 'zhipu':
normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName);
normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName, profileEmail);
break;
default:
console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider);
@@ -921,7 +1383,10 @@ export class UsageMonitor extends EventEmitter {
}
if (this.isDebug) {
console.warn('[UsageMonitor:PROVIDER] Normalized usage:', {
console.warn('[UsageMonitor:API_FETCH] Fetch completed - usage:', {
profileId,
profileName,
email: normalizedUsage.profileEmail,
provider,
sessionPercent: normalizedUsage.sessionPercent,
weeklyPercent: normalizedUsage.weeklyPercent,
@@ -948,32 +1413,63 @@ export class UsageMonitor extends EventEmitter {
/**
* Normalize Anthropic API response to ClaudeUsageSnapshot
*
* Expected Anthropic response format:
* Actual Anthropic OAuth usage API response format:
* {
* "five_hour_utilization": 0.72, // 0.0-1.0
* "seven_day_utilization": 0.45, // 0.0-1.0
* "five_hour_reset_at": "2025-01-17T15:00:00Z",
* "seven_day_reset_at": "2025-01-20T12:00:00Z"
* "five_hour": {
* "utilization": 19, // integer 0-100
* "resets_at": "2025-01-17T15:00:00Z"
* },
* "seven_day": {
* "utilization": 45, // integer 0-100
* "resets_at": "2025-01-20T12:00:00Z"
* }
* }
*/
private normalizeAnthropicResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot {
const fiveHourUtil = data.five_hour_utilization ?? 0;
const sevenDayUtil = data.seven_day_utilization ?? 0;
// Support both new nested format and legacy flat format for backward compatibility
//
// NEW format (current API): { five_hour: { utilization: 72, resets_at: "..." } }
// OLD format (legacy): { five_hour_utilization: 0.72, five_hour_reset_at: "..." }
let fiveHourUtil: number;
let sevenDayUtil: number;
let sessionResetTimestamp: string | undefined;
let weeklyResetTimestamp: string | undefined;
// Check for new nested format first
if (data.five_hour !== undefined || data.seven_day !== undefined) {
// New nested format - utilization is already 0-100 integer
fiveHourUtil = data.five_hour?.utilization ?? 0;
sevenDayUtil = data.seven_day?.utilization ?? 0;
sessionResetTimestamp = data.five_hour?.resets_at;
weeklyResetTimestamp = data.seven_day?.resets_at;
} else {
// Legacy flat format - utilization is 0-1 float, needs *100
const rawFiveHour = data.five_hour_utilization ?? 0;
const rawSevenDay = data.seven_day_utilization ?? 0;
// Convert 0-1 float to 0-100 integer
fiveHourUtil = Math.round(rawFiveHour * 100);
sevenDayUtil = Math.round(rawSevenDay * 100);
sessionResetTimestamp = data.five_hour_reset_at;
weeklyResetTimestamp = data.seven_day_reset_at;
}
return {
sessionPercent: Math.round(fiveHourUtil * 100),
weeklyPercent: Math.round(sevenDayUtil * 100),
sessionPercent: fiveHourUtil,
weeklyPercent: sevenDayUtil,
// Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining
sessionResetTime: undefined,
weeklyResetTime: undefined,
sessionResetTimestamp: data.five_hour_reset_at,
weeklyResetTimestamp: data.seven_day_reset_at,
sessionResetTimestamp,
weeklyResetTimestamp,
profileId,
profileName,
profileEmail,
fetchedAt: new Date(),
limitType: sevenDayUtil > fiveHourUtil ? 'weekly' : 'session',
usageWindows: {
@@ -992,6 +1488,7 @@ export class UsageMonitor extends EventEmitter {
* @param data - Raw response data with limits array
* @param profileId - Profile identifier
* @param profileName - Profile display name
* @param profileEmail - Optional email associated with the profile
* @param providerName - Provider name for logging ('zai' or 'zhipu')
* @returns Normalized usage snapshot or null on parse failure
*/
@@ -999,6 +1496,7 @@ export class UsageMonitor extends EventEmitter {
data: any,
profileId: string,
profileName: string,
profileEmail: string | undefined,
providerName: 'zai' | 'zhipu'
): ClaudeUsageSnapshot | null {
const logPrefix = providerName.toUpperCase();
@@ -1098,6 +1596,7 @@ export class UsageMonitor extends EventEmitter {
weeklyResetTimestamp,
profileId,
profileName,
profileEmail,
fetchedAt: new Date(),
limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session',
usageWindows: {
@@ -1146,10 +1645,11 @@ export class UsageMonitor extends EventEmitter {
private normalizeZAIResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot | null {
// Delegate to shared quota/limit response normalization
return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zai');
return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zai');
}
/**
@@ -1163,10 +1663,11 @@ export class UsageMonitor extends EventEmitter {
private normalizeZhipuResponse(
data: any,
profileId: string,
profileName: string
profileName: string,
profileEmail?: string
): ClaudeUsageSnapshot | null {
// Delegate to shared quota/limit response normalization
return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zhipu');
return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zhipu');
}
/**
@@ -1198,13 +1699,61 @@ export class UsageMonitor extends EventEmitter {
additionalExclusions: string[] = []
): Promise<void> {
const profileManager = getClaudeProfileManager();
// Get all profiles to swap to, excluding current and any additional exclusions
const allProfiles = profileManager.getProfilesSortedByAvailability();
const excludeIds = new Set([currentProfileId, ...additionalExclusions]);
const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id));
if (eligibleProfiles.length === 0) {
// Get priority order for unified account system
const priorityOrder = profileManager.getAccountPriorityOrder();
// Build unified list of available accounts
type UnifiedSwapTarget = {
id: string;
unifiedId: string; // oauth-{id} or api-{id}
name: string;
type: 'oauth' | 'api';
priorityIndex: number;
};
const unifiedAccounts: UnifiedSwapTarget[] = [];
// Add OAuth profiles (sorted by availability)
const oauthProfiles = profileManager.getProfilesSortedByAvailability();
for (const profile of oauthProfiles) {
if (!excludeIds.has(profile.id)) {
const unifiedId = `oauth-${profile.id}`;
const priorityIndex = priorityOrder.indexOf(unifiedId);
unifiedAccounts.push({
id: profile.id,
unifiedId,
name: profile.name,
type: 'oauth',
priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex
});
}
}
// Add API profiles (always considered available since they have unlimited usage)
try {
const profilesFile = await loadProfilesFile();
for (const apiProfile of profilesFile.profiles) {
if (!excludeIds.has(apiProfile.id) && apiProfile.apiKey) {
const unifiedId = `api-${apiProfile.id}`;
const priorityIndex = priorityOrder.indexOf(unifiedId);
unifiedAccounts.push({
id: apiProfile.id,
unifiedId,
name: apiProfile.name,
type: 'api',
priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex
});
}
}
} catch (error) {
if (this.isDebug) {
console.warn('[UsageMonitor] Failed to load API profiles for swap:', error);
}
}
if (unifiedAccounts.length === 0) {
console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')');
this.emit('proactive-swap-failed', {
reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative',
@@ -1214,8 +1763,22 @@ export class UsageMonitor extends EventEmitter {
return;
}
// Use the best available from eligible profiles
const bestProfile = eligibleProfiles[0];
// Sort by priority order (lower index = higher priority)
// If no priority order is set, OAuth profiles come first (they were already sorted by availability)
unifiedAccounts.sort((a, b) => {
// If both have priority indices, use them
if (a.priorityIndex !== Infinity || b.priorityIndex !== Infinity) {
return a.priorityIndex - b.priorityIndex;
}
// Otherwise, prefer OAuth profiles (which are sorted by availability)
if (a.type !== b.type) {
return a.type === 'oauth' ? -1 : 1;
}
return 0;
});
// Use the best available from unified accounts
const bestAccount = unifiedAccounts[0];
// Sort by priority order (lower index = higher priority)
// If no priority order is set, OAuth profiles come first (they were already sorted by availability)
@@ -1241,10 +1804,6 @@ export class UsageMonitor extends EventEmitter {
reason: limitType
});
// Clear cache for the profile that's becoming inactive
// This ensures the next fetch gets fresh data instead of stale cached values
this.clearProfileUsageCache(currentProfileId);
// Switch to the new profile
if (bestAccount.type === 'oauth') {
// Switch OAuth profile via profile manager
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
import { isWindows, isMacOS, isLinux } from '../platform';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { isValidConfigDir } from '../utils/config-path-validator';
import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
import { clearKeychainCache } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import semver from 'semver';
@@ -1370,7 +1370,7 @@ export function registerClaudeCodeHandlers(): void {
}
}
// If authenticated, update the profile with metadata from credentials
// If authenticated, update the profile with the email
// NOTE: We intentionally do NOT store the OAuth token in the profile.
// Storing the token causes AutoClaude to use a stale cached token instead of
// letting Claude CLI read fresh tokens from Keychain (which auto-refreshes).
@@ -1384,11 +1384,7 @@ export function registerClaudeCodeHandlers(): void {
profile.email = result.email;
}
// Update subscription metadata from Keychain credentials
// These are needed to display "Max" vs "Pro" in the UI
updateProfileSubscriptionMetadata(profile, expandedConfigDir);
// Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
profileManager.saveProfile(profile);
// CRITICAL: Clear keychain cache for this profile's configDir
@@ -4,8 +4,8 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_M
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, WorktreeCreatePROptions, WorktreeCreatePRResult, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types';
import path from 'path';
import { minimatch } from 'minimatch';
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { homedir } from 'os';
import { projectStore } from '../../project-store';
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
@@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator';
import { readSettingsFileAsync } from '../settings-utils';
import { debugLog, } from '../../shared/utils/debug-logger';
import { migrateSession } from '../claude-profile/session-utils';
import { createProfileDirectory } from '../claude-profile/profile-utils';
import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils';
import { isValidConfigDir } from '../utils/config-path-validator';
@@ -539,13 +539,12 @@ export function registerTerminalHandlers(
);
// Request all profiles usage immediately (for startup/refresh)
// Optional forceRefresh parameter bypasses cache to get fresh data
ipcMain.handle(
IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST,
async (_event: IpcMainInvokeEvent, forceRefresh: boolean = false): Promise<IPCResult<AllProfilesUsage | null>> => {
async (): Promise<IPCResult<AllProfilesUsage | null>> => {
try {
const monitor = getUsageMonitor();
const allProfilesUsage = await monitor.getAllProfilesUsage(forceRefresh);
const allProfilesUsage = await monitor.getAllProfilesUsage();
return { success: true, data: allProfilesUsage };
} catch (error) {
return {
@@ -365,48 +365,6 @@ export function isAuthFailureError(output: string): boolean {
return detectAuthFailure(output).isAuthFailure;
}
/**
* Detect billing failure from output (stdout + stderr combined)
*/
export function detectBillingFailure(
output: string,
profileId?: string
): BillingFailureDetectionResult {
// First, make sure this isn't a rate limit or auth error (those should be handled separately)
if (detectRateLimit(output).isRateLimited) {
return { isBillingFailure: false };
}
if (detectAuthFailure(output).isAuthFailure) {
return { isBillingFailure: false };
}
// Check for billing failure patterns
for (const pattern of BILLING_FAILURE_PATTERNS) {
if (pattern.test(output)) {
const profileManager = getClaudeProfileManager();
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
const failureType = classifyBillingFailureType(output);
return {
isBillingFailure: true,
profileId: effectiveProfileId,
failureType,
message: getBillingFailureMessage(failureType),
originalError: sanitizeErrorOutput(output)
};
}
}
return { isBillingFailure: false };
}
/**
* Check if output contains billing failure error
*/
export function isBillingFailureError(output: string): boolean {
return detectBillingFailure(output).isBillingFailure;
}
/**
* Get environment variables for a specific Claude profile.
*
@@ -434,188 +392,6 @@ export function getProfileEnv(profileId?: string): Record<string, string> {
return profileManager.getActiveProfileEnv();
}
/**
* Result of getting the best available profile environment
*/
export interface BestProfileEnvResult {
/** Environment variables for the selected profile */
env: Record<string, string>;
/** The profile ID that was selected */
profileId: string;
/** The profile name for logging/display */
profileName: string;
/** Whether a swap was performed (true if different from active profile) */
wasSwapped: boolean;
/** Reason for the swap if one occurred */
swapReason?: 'rate_limited' | 'at_capacity' | 'proactive';
/** The original active profile if a swap occurred */
originalProfile?: {
id: string;
name: string;
};
}
/**
* Get environment variables for the BEST available Claude profile and persist the profile swap.
*
* IMPORTANT: This function has the side effect of calling profileManager.setActiveProfile()
* when a better profile is found. This modifies global state and persists the profile swap.
*
* This is the preferred function for SDK operations that need profile environment.
* It automatically handles:
* 1. Checking if the active profile is explicitly rate-limited (received 429/rate limit error)
* 2. Checking if the active profile is at capacity (100% weekly usage)
* 3. Finding a better alternative profile if available
* 4. PERSISTING the swap by updating the active profile
*
* Use this instead of getProfileEnv() for any operation that will make Claude API calls.
*
* @returns Object containing env vars and metadata about which profile was selected
*/
export function getBestAvailableProfileEnv(): BestProfileEnvResult {
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
// Check for explicit rate limit (from previous API errors)
const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id);
// Check for capacity limit (100% weekly usage - will be rate limited on next request)
const isAtCapacity = activeProfile.usage?.weeklyUsagePercent !== undefined &&
activeProfile.usage.weeklyUsagePercent >= 100;
// Determine if we need to find an alternative
const needsSwap = rateLimitStatus.limited || isAtCapacity;
const swapReason: BestProfileEnvResult['swapReason'] = rateLimitStatus.limited
? 'rate_limited'
: isAtCapacity
? 'at_capacity'
: undefined;
if (needsSwap) {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] Active profile needs swap:', {
activeProfile: activeProfile.name,
isRateLimited: rateLimitStatus.limited,
isAtCapacity,
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
limitType: rateLimitStatus.type,
resetAt: rateLimitStatus.resetAt
});
}
// Try to find a better profile
const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id);
if (bestProfile) {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] Using alternative profile:', {
originalProfile: activeProfile.name,
alternativeProfile: bestProfile.name,
reason: swapReason
});
}
// Persist the swap by updating the active profile
// This ensures the UI reflects which account is actually being used
profileManager.setActiveProfile(bestProfile.id);
console.warn('[RateLimitDetector] Switched active profile:', {
from: activeProfile.name,
to: bestProfile.name,
reason: swapReason
});
// Trigger a usage refresh so the UI shows the new active profile
// This updates the UsageIndicator in the header
// We use fire-and-forget pattern to avoid making this function async
try {
const usageMonitor = getUsageMonitor();
// Force refresh all profiles usage data, which will emit 'all-profiles-usage-updated' event
// The UI components listen for this and will update automatically
usageMonitor.getAllProfilesUsage(true).then((allProfilesUsage) => {
if (allProfilesUsage) {
// Find the new active profile in allProfiles and emit its usage
// This ensures UsageIndicator.usage state also updates to show the new active account
const newActiveProfile = allProfilesUsage.allProfiles.find(p => p.isActive);
if (newActiveProfile) {
// Construct a ClaudeUsageSnapshot for the new active profile
const newActiveUsage = {
profileId: newActiveProfile.profileId,
profileName: newActiveProfile.profileName,
profileEmail: newActiveProfile.profileEmail,
sessionPercent: newActiveProfile.sessionPercent,
weeklyPercent: newActiveProfile.weeklyPercent,
sessionResetTimestamp: newActiveProfile.sessionResetTimestamp,
weeklyResetTimestamp: newActiveProfile.weeklyResetTimestamp,
fetchedAt: allProfilesUsage.fetchedAt,
needsReauthentication: newActiveProfile.needsReauthentication,
};
usageMonitor.emit('usage-updated', newActiveUsage);
}
// Also emit all-profiles-usage-updated for the other profiles list
usageMonitor.emit('all-profiles-usage-updated', allProfilesUsage);
}
}).catch((err) => {
console.warn('[RateLimitDetector] Failed to refresh usage after swap:', err);
});
} catch (err) {
// Usage monitor may not be initialized yet, that's OK
console.warn('[RateLimitDetector] Could not trigger usage refresh:', err);
}
const profileEnv = profileManager.getProfileEnv(bestProfile.id);
return {
env: ensureCleanProfileEnv(profileEnv),
profileId: bestProfile.id,
profileName: bestProfile.name,
wasSwapped: true,
swapReason,
originalProfile: {
id: activeProfile.id,
name: activeProfile.name
}
};
} else {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
}
}
}
// Use active profile (either it's fine, or no better alternative exists)
const activeEnv = profileManager.getActiveProfileEnv();
return {
env: ensureCleanProfileEnv(activeEnv),
profileId: activeProfile.id,
profileName: activeProfile.name,
wasSwapped: false
};
}
/**
* Ensure the profile environment is clean for subprocess invocation.
*
* When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent
* the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file)
* instead of reading fresh credentials from the specified config directory.
*
* This is critical for multi-account switching: when switching from a rate-limited
* account to an available one, the subprocess must use the new account's credentials.
*
* @param env - Profile environment from getProfileEnv() or getActiveProfileEnv()
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set
*/
function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
if (env.CLAUDE_CONFIG_DIR) {
// Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
return {
...env,
CLAUDE_CODE_OAUTH_TOKEN: ''
};
}
return env;
}
/**
* Get the active Claude profile ID
*/
@@ -10,8 +10,7 @@ import * as path from 'path';
import * as crypto from 'crypto';
import { IPC_CHANNELS } from '../../shared/constants';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import { getFullCredentialsFromKeychain, clearKeychainCache, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils';
import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
import * as OutputParser from './output-parser';
import * as SessionHandler from './session-handler';
@@ -525,8 +524,6 @@ export function handleOAuthToken(
if (email) {
profile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, keychainCreds);
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
@@ -613,8 +610,6 @@ export function handleOAuthToken(
if (email) {
profile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, profile.configDir);
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
@@ -677,8 +672,6 @@ export function handleOAuthToken(
if (email) {
activeProfile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(activeProfile, activeProfile.configDir);
activeProfile.isAuthenticated = true;
profileManager.saveProfile(activeProfile);
@@ -772,13 +765,11 @@ export function handleOnboardingComplete(
bufferLength: terminal.outputBuffer.length
});
// Update profile with email and subscription metadata if found and profile exists
// Update profile with email if found and profile exists
// Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email
if (profileId && email && profile) {
const previousEmail = profile.email;
profile.email = email;
// Also update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, profile.configDir);
profileManager.saveProfile(profile);
if (previousEmail !== email) {
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')');
@@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null {
for (const pattern of EMAIL_PATTERNS) {
const match = cleanData.match(pattern);
if (match?.[1]) {
if (match && match[1]) {
return match[1];
}
}
@@ -120,7 +120,7 @@ export interface TerminalAPI {
// Usage Monitoring (Proactive Account Switching)
requestUsageUpdate: () => Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>>;
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
requestAllProfilesUsage: () => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void;
onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void;
onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void;
@@ -518,8 +518,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
requestUsageUpdate: (): Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST),
requestAllProfilesUsage: (forceRefresh?: boolean): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, forceRefresh ?? false),
requestAllProfilesUsage: (): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST),
onUsageUpdated: (
callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void
@@ -6,8 +6,13 @@
* - Click to pin popup open (stays until clicking outside)
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from './ui/popover';
import {
Tooltip,
TooltipContent,
@@ -16,33 +21,87 @@ import {
} from './ui/tooltip';
import { useTranslation } from 'react-i18next';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
import type { ClaudeUsageSnapshot, ProfileUsageSummary } from '../../shared/types/agent';
import type { AppSection } from './settings/AppSettings';
/**
* Usage threshold constants for color coding
*/
const THRESHOLD_CRITICAL = 95; // Red: At or near limit
const THRESHOLD_WARNING = 91; // Orange: Very high usage
const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage
// Below 71 is considered normal (green)
/**
* Get color class based on usage percentage
*/
const getColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'text-red-500';
if (percent >= THRESHOLD_WARNING) return 'text-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500';
return 'text-green-500';
};
/**
* Get background/border color classes for badges based on usage percentage
*/
const getBadgeColorClasses = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'text-red-500 bg-red-500/10 border-red-500/20';
if (percent >= THRESHOLD_WARNING) return 'text-orange-500 bg-orange-500/10 border-orange-500/20';
if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20';
return 'text-green-500 bg-green-500/10 border-green-500/20';
};
/**
* Get gradient background class based on usage percentage
*/
const getGradientClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'bg-gradient-to-r from-red-600 to-red-500';
if (percent >= THRESHOLD_WARNING) return 'bg-gradient-to-r from-orange-600 to-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'bg-gradient-to-r from-yellow-600 to-yellow-500';
return 'bg-gradient-to-r from-green-600 to-green-500';
};
/**
* Get background class for small usage bars based on usage percentage
*/
const getBarColorClass = (percent: number): string => {
if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500';
if (percent >= THRESHOLD_WARNING) return 'bg-orange-500';
if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500';
return 'bg-green-500';
};
export function UsageIndicator() {
const { t, i18n } = useTranslation(['common']);
const [usage, setUsage] = useState<ClaudeUsageSnapshot | null>(null);
const [otherProfiles, setOtherProfiles] = useState<ProfileUsageSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAvailable, setIsAvailable] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
/**
* Helper function to get initials from a profile name
*/
const getInitials = (name: string): string => {
if (!name || name.trim().length === 0) {
return 'UN'; // Unknown
}
const words = name.trim().split(/\s+/);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
/**
* Helper function to format large numbers with locale-aware compact notation
*
* Returns undefined for null/undefined values. The caller (JSX conditional guards)
* is responsible for checking values before calling this function.
*
* @param value - The number to format (undefined, null, or number)
* @returns Formatted compact number string (e.g., "1.2K", "3.4M"), or undefined if input is null/undefined
*
* @example
* formatUsageValue(1234) // "1.2K" (en-US)
* formatUsageValue(null) // undefined
* formatUsageValue(undefined) // undefined
*/
const formatUsageValue = (value?: number | null): string | undefined => {
if (value == null) return undefined;
// Use Intl.NumberFormat for locale-aware compact number formatting
// Fallback to toString() if Intl is not available
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
try {
return new Intl.NumberFormat(i18n.language, {
@@ -57,8 +116,172 @@ export function UsageIndicator() {
return value.toString();
};
/**
* Navigate to settings accounts tab
*/
const handleOpenAccounts = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Close the popover first
setIsOpen(false);
setIsPinned(false);
// Dispatch custom event to open settings with accounts section
// Small delay to allow popover to close first
setTimeout(() => {
const event = new CustomEvent<AppSection>('open-app-settings', {
detail: 'accounts'
});
window.dispatchEvent(event);
}, 100);
}, []);
/**
* Handle swapping to a different profile
* Uses optimistic UI update for immediate feedback, then fetches fresh data
*/
const handleSwapProfile = useCallback(async (e: React.MouseEvent, profileId: string) => {
e.preventDefault();
e.stopPropagation();
// Capture previous state for revert (before any changes)
const previousUsage = usage;
const previousOtherProfiles = otherProfiles;
// Find the profile we're swapping to
const targetProfile = otherProfiles.find(p => p.profileId === profileId);
if (!targetProfile) {
console.error('[UsageIndicator] Target profile not found:', profileId);
return;
}
// Optimistic update: immediately swap profiles in the UI
// 1. Convert current active profile to a ProfileUsageSummary for the "other" list
const currentActiveAsSummary: ProfileUsageSummary = {
profileId: usage?.profileId || '',
profileName: usage?.profileName || '',
profileEmail: usage?.profileEmail,
sessionPercent: usage?.sessionPercent || 0,
weeklyPercent: usage?.weeklyPercent || 0,
sessionResetTimestamp: usage?.sessionResetTimestamp,
weeklyResetTimestamp: usage?.weeklyResetTimestamp,
isAuthenticated: true,
isRateLimited: false,
availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0),
isActive: false, // It's no longer active
};
// 2. Convert target profile to a ClaudeUsageSnapshot for the active display
const newActiveUsage: ClaudeUsageSnapshot = {
profileId: targetProfile.profileId,
profileName: targetProfile.profileName,
profileEmail: targetProfile.profileEmail,
sessionPercent: targetProfile.sessionPercent,
weeklyPercent: targetProfile.weeklyPercent,
sessionResetTimestamp: targetProfile.sessionResetTimestamp,
weeklyResetTimestamp: targetProfile.weeklyResetTimestamp,
fetchedAt: new Date(),
};
// 3. Update the other profiles list: remove target, add current active
const newOtherProfiles = otherProfiles
.filter(p => p.profileId !== profileId)
.concat(usage ? [currentActiveAsSummary] : [])
.sort((a, b) => b.availabilityScore - a.availabilityScore);
// Apply optimistic update immediately
setUsage(newActiveUsage);
setOtherProfiles(newOtherProfiles);
try {
// Actually switch the profile on the backend
const result = await window.electronAPI.setActiveClaudeProfile(profileId);
if (result.success) {
// Fetch fresh data in the background (will update via event listeners)
window.electronAPI.requestUsageUpdate();
window.electronAPI.requestAllProfilesUsage?.();
} else {
// Revert to captured previous state
console.error('[UsageIndicator] Failed to swap profile, reverting');
if (previousUsage) setUsage(previousUsage);
setOtherProfiles(previousOtherProfiles);
}
} catch (error) {
console.error('[UsageIndicator] Failed to swap profile:', error);
// Revert to captured previous state
if (previousUsage) setUsage(previousUsage);
setOtherProfiles(previousOtherProfiles);
}
}, [usage, otherProfiles]);
/**
* Handle mouse enter - show popup after short delay (unless pinned)
*/
const handleMouseEnter = useCallback(() => {
if (isPinned) return;
// Clear any pending close timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Open after short delay for smoother UX
hoverTimeoutRef.current = setTimeout(() => {
setIsOpen(true);
}, 150);
}, [isPinned]);
/**
* Handle mouse leave - close popup after delay (unless pinned)
*/
const handleMouseLeave = useCallback(() => {
if (isPinned) return;
// Clear any pending open timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Close after delay to allow moving to popup content
hoverTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
}, 300);
}, [isPinned]);
/**
* Handle click on trigger - toggle pinned state
*/
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
if (isPinned) {
// Clicking when pinned unpins and closes
setIsPinned(false);
setIsOpen(false);
} else {
// Clicking when not pinned pins it open
setIsPinned(true);
setIsOpen(true);
}
}, [isPinned]);
/**
* Handle popover open change (e.g., clicking outside)
*/
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
// Closing from outside click
setIsOpen(false);
setIsPinned(false);
}
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
// Get formatted reset times (calculated dynamically from timestamps)
// Only fall back to sessionResetTime/weeklyResetTime if they don't contain placeholder/hardcoded text
const sessionResetTime = usage?.sessionResetTimestamp
? (formatTimeRemaining(usage.sessionResetTimestamp, t) ??
(hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime))
@@ -76,6 +299,13 @@ export function UsageIndicator() {
setIsLoading(false);
});
// Listen for all profiles usage updates (for multi-profile display)
const unsubscribeAllProfiles = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => {
// Filter out the active profile - we only want to show "other" profiles
const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
});
// Request initial usage on mount
window.electronAPI.requestUsageUpdate().then((result) => {
setIsLoading(false);
@@ -83,24 +313,31 @@ export function UsageIndicator() {
setUsage(result.data);
setIsAvailable(true);
} else {
// No usage data available (endpoint not supported or error)
setIsAvailable(false);
}
}).catch((error) => {
// Handle errors (IPC failure, network issues, etc.)
console.warn('[UsageIndicator] Failed to fetch initial usage:', error);
setIsLoading(false);
setIsAvailable(false);
});
// Request all profiles usage immediately on mount (so other accounts show right away)
window.electronAPI.requestAllProfilesUsage?.().then((result) => {
if (result.success && result.data) {
const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
}
}).catch((error) => {
console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error);
});
return () => {
unsubscribe();
unsubscribeAllProfiles?.();
};
}, []);
// Always show the badge, but display different states
// Show loading state initially
// Show loading state
if (isLoading) {
return (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground">
@@ -110,7 +347,7 @@ export function UsageIndicator() {
);
}
// Show unavailable state when endpoint doesn't return data
// Show unavailable state
if (!isAvailable || !usage) {
return (
<TooltipProvider delayDuration={200}>
@@ -134,17 +371,18 @@ export function UsageIndicator() {
);
}
// Determine color based on session usage (5-hour window)
// This is what should be shown on the badge per QA feedback
const badgeUsage = usage.sessionPercent;
const badgeColorClasses =
badgeUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' :
badgeUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' :
badgeUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' :
'text-green-500 bg-green-500/10 border-green-500/20';
// Determine colors and labels based on the LIMITING factor (higher of session/weekly)
const sessionPercent = usage.sessionPercent;
const weeklyPercent = usage.weeklyPercent;
const limitingPercent = Math.max(sessionPercent, weeklyPercent);
// Badge color based on the limiting (higher) percentage
const badgeColorClasses = getBadgeColorClasses(limitingPercent);
// Individual colors for session and weekly in the badge
const sessionColorClass = getColorClass(sessionPercent);
const weeklyColorClass = getColorClass(weeklyPercent);
// Get window labels for display
// Map backend-provided labels to localized versions with appropriate defaults
const sessionLabel = localizeUsageWindowLabel(
usage?.usageWindows?.sessionWindowLabel,
t,
@@ -156,143 +394,33 @@ export function UsageIndicator() {
'common:usage.weeklyDefault'
);
// For icon, use the highest of the two windows
const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
const Icon =
maxUsage >= 91 ? AlertCircle :
maxUsage >= 71 ? TrendingUp :
maxUsage >= THRESHOLD_WARNING ? AlertCircle :
maxUsage >= THRESHOLD_ELEVATED ? TrendingUp :
Activity;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${badgeColorClasses}`}
aria-label={t('common:usage.usageStatusAriaLabel')}
>
<Icon className="h-3.5 w-3.5" />
<span className="text-xs font-semibold font-mono">
{Math.round(badgeUsage)}%
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
className={`flex items-center gap-1 px-2 py-1.5 rounded-md border transition-all hover:opacity-80 ${badgeColorClasses}`}
aria-label={t('common:usage.usageStatusAriaLabel')}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleTriggerClick}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
{/* Dual usage display: Session | Weekly */}
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
{Math.round(sessionPercent)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs w-72 p-0">
<div className="p-3 space-y-3">
{/* Header with overall status */}
<div className="flex items-center pb-2 border-b">
<Icon className="h-3.5 w-3.5" />
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
</div>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${
usage.sessionPercent >= 95 ? 'text-red-500' :
usage.sessionPercent >= 91 ? 'text-orange-500' :
usage.sessionPercent >= 71 ? 'text-yellow-600' :
'text-green-600'
}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
{/* Enhanced progress bar with gradient */}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
usage.sessionPercent >= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' :
usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
'bg-gradient-to-r from-green-600 to-green-500'
}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
{/* Subtle shine effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{/* Raw usage value with better styling */}
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
</div>
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${
usage.weeklyPercent >= 99 ? 'text-red-500' :
usage.weeklyPercent >= 91 ? 'text-orange-500' :
usage.weeklyPercent >= 71 ? 'text-yellow-600' :
'text-green-600'
}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
)}
{/* Enhanced progress bar with gradient */}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
usage.weeklyPercent >= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' :
usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
'bg-gradient-to-r from-green-600 to-green-500'
}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
{/* Subtle shine effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{/* Raw usage value with better styling */}
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
{/* Active account footer */}
<div className="pt-2 border-t flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<User className="h-3 w-3" />
<span>{t('common:usage.activeAccount')}</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-primary">
<span>{usage.profileName}</span>
<ChevronRight className="h-3 w-3" />
</div>
</div>
)}
<span className="text-muted-foreground/50"></span>
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
{Math.round(weeklyPercent)}
</span>
</div>
</button>
</PopoverTrigger>
<PopoverContent
@@ -309,102 +437,75 @@ export function UsageIndicator() {
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
</div>
{/* Re-auth required prompt - shown when active profile needs re-authentication */}
{usage.needsReauthentication ? (
<div className="py-2 space-y-3">
<div className="flex items-start gap-2.5 p-2.5 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-medium text-destructive">
{t('common:usage.reauthRequired')}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('common:usage.reauthRequiredDescription')}
</p>
</div>
</div>
<button
type="button"
onClick={handleOpenAccounts}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors text-xs font-medium"
>
<LogIn className="h-3.5 w-3.5" />
{t('common:usage.reauthButton')}
</button>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
) : (
<>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
</div>
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
</>
)}
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
{/* Active account footer - clickable to go to settings */}
<button
@@ -412,21 +513,11 @@ export function UsageIndicator() {
onClick={handleOpenAccounts}
className={`w-full pt-3 border-t flex items-center gap-2.5 hover:bg-muted/50 -mx-3 px-3 ${otherProfiles.length === 0 ? '-mb-3 pb-3 rounded-b-md' : 'pb-2'} transition-colors cursor-pointer group`}
>
{/* Initials Avatar with warning indicator for re-auth needed */}
<div className="relative">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
usage.needsReauthentication ? 'bg-red-500/10' : 'bg-primary/10'
}`}>
<span className={`text-xs font-semibold ${
usage.needsReauthentication ? 'text-red-500' : 'text-primary'
}`}>
{getInitials(usage.profileName)}
</span>
</div>
{/* Status dot for re-auth needed */}
{usage.needsReauthentication && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
{/* Initials Avatar */}
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-primary">
{getInitials(usage.profileName)}
</span>
</div>
{/* Account Info */}
@@ -435,15 +526,8 @@ export function UsageIndicator() {
<span className="text-[10px] text-muted-foreground font-medium">
{t('common:usage.activeAccount')}
</span>
{usage.needsReauthentication && (
<span className="text-[9px] px-1.5 py-0.5 bg-red-500/10 text-destructive rounded font-semibold">
{t('common:usage.needsReauth')}
</span>
)}
</div>
<div className={`font-medium text-xs truncate ${
usage.needsReauthentication ? 'text-destructive' : 'text-primary'
}`}>
<div className="font-medium text-xs text-primary truncate">
{usage.profileEmail || usage.profileName}
</div>
</div>
@@ -466,14 +550,14 @@ export function UsageIndicator() {
{/* Initials Avatar with status indicator */}
<div className="relative">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
profile.isRateLimited || profile.needsReauthentication
profile.isRateLimited
? 'bg-red-500/10'
: !profile.isAuthenticated
? 'bg-muted'
: 'bg-muted/80'
}`}>
<span className={`text-[10px] font-semibold ${
profile.isRateLimited || profile.needsReauthentication
profile.isRateLimited
? 'text-red-500'
: !profile.isAuthenticated
? 'text-muted-foreground'
@@ -483,7 +567,7 @@ export function UsageIndicator() {
</span>
</div>
{/* Status dot */}
{(profile.isRateLimited || profile.needsReauthentication) && (
{profile.isRateLimited && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
</div>
@@ -516,10 +600,6 @@ export function UsageIndicator() {
? t('common:usage.weeklyLimitReached')
: t('common:usage.sessionLimitReached')}
</span>
) : profile.needsReauthentication ? (
<span className="text-[9px] text-destructive">
{t('common:usage.needsReauth')}
</span>
) : !profile.isAuthenticated ? (
<span className="text-[9px] text-muted-foreground">
{t('common:usage.notAuthenticated')}
@@ -102,8 +102,6 @@ export interface UnifiedAccount {
isAuthenticated?: boolean;
/** Set when this account has identical usage to another - may indicate same underlying account */
isDuplicateUsage?: boolean;
/** Set when this account has an invalid refresh token and needs re-authentication */
needsReauthentication?: boolean;
}
interface SortableAccountItemProps {
@@ -283,23 +281,6 @@ function SortableAccountItem({ account, index }: SortableAccountItemProps) {
</TooltipContent>
</Tooltip>
)}
{/* Needs re-authentication warning - invalid refresh token */}
{account.type === 'oauth' && account.needsReauthentication && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mt-1.5 cursor-help">
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-[10px] text-destructive">
{t('accounts.priority.needsReauth')}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
{t('accounts.priority.needsReauthHint')}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Right side badge for API profiles */}
@@ -28,9 +28,7 @@ import {
Activity,
AlertCircle,
Server,
Globe,
Clock,
TrendingUp
Globe
} from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -138,10 +136,9 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
const [profileUsageData, setProfileUsageData] = useState<Map<string, ProfileUsageSummary>>(new Map());
// Fetch all profiles usage data
// Force refresh to get fresh data when Settings opens (bypasses 1-minute cache)
const loadProfileUsageData = useCallback(async (forceRefresh: boolean = false) => {
const loadProfileUsageData = useCallback(async () => {
try {
const result = await window.electronAPI.requestAllProfilesUsage?.(forceRefresh);
const result = await window.electronAPI.requestAllProfilesUsage?.();
if (result?.success && result.data) {
const usageMap = new Map<string, ProfileUsageSummary>();
result.data.allProfiles.forEach(profile => {
@@ -177,7 +174,6 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
isRateLimited: usageData?.isRateLimited,
rateLimitType: usageData?.rateLimitType,
isAuthenticated: profile.isAuthenticated,
needsReauthentication: usageData?.needsReauthentication,
});
});
@@ -251,11 +247,8 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
loadClaudeProfiles();
loadAutoSwitchSettings();
loadPriorityOrder();
// Force refresh usage data when Settings opens to get fresh data
// This bypasses the 1-minute cache to ensure accurate duplicate detection
loadProfileUsageData(true);
loadProfileUsageData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, loadProfileUsageData]);
// Subscribe to usage updates for real-time data
@@ -340,7 +333,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
});
}
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.addProfileFailed'),
@@ -370,7 +363,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
description: result.error || t('accounts.toast.tryAgain'),
});
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.deleteProfileFailed'),
@@ -405,7 +398,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
description: result.error || t('accounts.toast.tryAgain'),
});
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.renameProfileFailed'),
@@ -436,7 +429,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
description: result.error || t('accounts.toast.tryAgain'),
});
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.setActiveProfileFailed'),
@@ -488,7 +481,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
setAuthTerminal(null);
setAuthenticatingProfileId(null);
await loadClaudeProfiles();
}, [loadClaudeProfiles]);
}, []);
const handleAuthTerminalError = useCallback(() => {
// Don't auto-close on error
@@ -535,7 +528,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
description: result.error || t('accounts.toast.tryAgain'),
});
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.tokenSaveFailed'),
@@ -646,7 +639,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
description: result.error || t('accounts.toast.tryAgain'),
});
}
} catch (_err) {
} catch (err) {
toast({
variant: 'destructive',
title: t('accounts.toast.settingsUpdateFailed'),
@@ -697,21 +690,14 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
</div>
) : (
<div className="space-y-2 mb-4">
{claudeProfiles.map((profile) => {
// Get usage data to check needsReauthentication flag
const usageData = profileUsageData.get(profile.id);
const needsReauth = usageData?.needsReauthentication ?? false;
return (
{claudeProfiles.map((profile) => (
<div
key={profile.id}
className={cn(
"rounded-lg border transition-colors",
needsReauth
? "border-destructive/50 bg-destructive/5"
: profile.id === activeClaudeProfileId && !activeApiProfileId
? "border-primary bg-primary/5"
: "border-border bg-background"
profile.id === activeClaudeProfileId && !activeApiProfileId
? "border-primary bg-primary/5"
: "border-border bg-background"
)}
>
<div className={cn(
@@ -770,12 +756,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
{t('accounts.claudeCode.active')}
</span>
)}
{needsReauth ? (
<span className="text-xs bg-destructive/20 text-destructive px-1.5 py-0.5 rounded flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{t('accounts.priority.needsReauth')}
</span>
) : profile.isAuthenticated ? (
{profile.isAuthenticated ? (
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
<Check className="h-3 w-3" />
{t('accounts.claudeCode.authenticated')}
@@ -789,57 +770,6 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
{profile.email && (
<span className="text-xs text-muted-foreground">{profile.email}</span>
)}
{/* Usage bars - show if we have usage data */}
{usageData && profile.isAuthenticated && !needsReauth && (
<div className="flex items-center gap-3 mt-1.5">
{/* Session usage */}
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
(usageData.sessionPercent ?? 0) >= 95 ? 'bg-red-500' :
(usageData.sessionPercent ?? 0) >= 91 ? 'bg-orange-500' :
(usageData.sessionPercent ?? 0) >= 71 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(usageData.sessionPercent ?? 0, 100)}%` }}
/>
</div>
<span className={`text-[10px] tabular-nums w-7 ${
(usageData.sessionPercent ?? 0) >= 95 ? 'text-red-500' :
(usageData.sessionPercent ?? 0) >= 91 ? 'text-orange-500' :
(usageData.sessionPercent ?? 0) >= 71 ? 'text-yellow-500' :
'text-muted-foreground'
}`}>
{Math.round(usageData.sessionPercent ?? 0)}%
</span>
</div>
{/* Weekly usage */}
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-muted-foreground" />
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
(usageData.weeklyPercent ?? 0) >= 95 ? 'bg-red-500' :
(usageData.weeklyPercent ?? 0) >= 91 ? 'bg-orange-500' :
(usageData.weeklyPercent ?? 0) >= 71 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(usageData.weeklyPercent ?? 0, 100)}%` }}
/>
</div>
<span className={`text-[10px] tabular-nums w-7 ${
(usageData.weeklyPercent ?? 0) >= 95 ? 'text-red-500' :
(usageData.weeklyPercent ?? 0) >= 91 ? 'text-orange-500' :
(usageData.weeklyPercent ?? 0) >= 71 ? 'text-yellow-500' :
'text-muted-foreground'
}`}>
{Math.round(usageData.weeklyPercent ?? 0)}%
</span>
</div>
</div>
)}
</>
)}
</div>
@@ -1022,8 +952,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
</div>
)}
</div>
);
})}
))}
</div>
)}
@@ -1329,11 +1258,11 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
<input
id="session-threshold"
type="range"
min="0"
min="70"
max="99"
step="1"
value={autoSwitchSettings?.sessionThreshold ?? 95}
onChange={(e) => handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value, 10) })}
onChange={(e) => handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })}
disabled={isLoadingAutoSwitch}
className="w-full"
aria-describedby="session-threshold-description"
@@ -1352,11 +1281,11 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
<input
id="weekly-threshold"
type="range"
min="0"
min="70"
max="99"
step="1"
value={autoSwitchSettings?.weeklyThreshold ?? 99}
onChange={(e) => handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value, 10) })}
onChange={(e) => handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })}
disabled={isLoadingAutoSwitch}
className="w-full"
aria-describedby="weekly-threshold-description"
@@ -1387,23 +1316,6 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
disabled={isLoadingAutoSwitch}
/>
</div>
{/* Auto-switch on auth failure */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
{t('accounts.autoSwitching.autoSwitchOnAuthFailure')}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{t('accounts.autoSwitching.autoSwitchOnAuthFailureDescription')}
</p>
</div>
<Switch
checked={autoSwitchSettings?.autoSwitchOnAuthFailure ?? false}
onCheckedChange={(value) => handleUpdateAutoSwitch({ autoSwitchOnAuthFailure: value })}
disabled={isLoadingAutoSwitch}
/>
</div>
</div>
{/* Account Priority Order */}
@@ -18,7 +18,6 @@ import {
Globe,
Code,
Bug,
Terminal,
Users
} from 'lucide-react';
@@ -51,7 +50,6 @@ import { GeneralSettings } from './GeneralSettings';
import { AdvancedSettings } from './AdvancedSettings';
import { DevToolsSettings } from './DevToolsSettings';
import { DebugSettings } from './DebugSettings';
import { TerminalFontSettings } from './terminal-font-settings/TerminalFontSettings';
import { AccountSettings } from './AccountSettings';
import { ProjectSelector } from './ProjectSelector';
import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent';
@@ -67,7 +65,7 @@ interface AppSettingsDialogProps {
}
// App-level settings sections
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'terminal-fonts' | 'agent' | 'paths' | 'integrations' | 'accounts' | 'api-profiles' | 'updates' | 'notifications' | 'debug';
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'accounts' | 'updates' | 'notifications' | 'debug';
interface NavItemConfig<T extends string> {
id: T;
@@ -76,7 +76,7 @@ export const claudeProfileMock = {
data: null
}),
requestAllProfilesUsage: async (_forceRefresh?: boolean) => ({
requestAllProfilesUsage: async () => ({
success: true,
data: null
}),
@@ -106,8 +106,7 @@
"optional": "Optional",
"required": "Required",
"dismiss": "Dismiss",
"important": "Important",
"orphaned": "(orphaned)"
"important": "Important"
},
"selection": {
"select": "Select",
@@ -475,7 +474,15 @@
"window5Hour": "5-hour window",
"window7Day": "7-day window",
"window5HoursQuota": "5 Hours Quota",
"windowMonthlyToolsQuota": "Monthly Tools Quota"
"windowMonthlyToolsQuota": "Monthly Tools Quota",
"otherAccounts": "Other Accounts",
"next": "Next",
"weeklyLimitReached": "Weekly limit reached",
"sessionLimitReached": "Session limit reached",
"notAuthenticated": "Not authenticated",
"sessionShort": "5-hour session usage",
"weeklyShort": "7-day weekly usage",
"swap": "Swap"
},
"oauth": {
"enterCode": "Manual Code Entry (Fallback)",
@@ -577,9 +577,7 @@
"weeklyThreshold": "Weekly usage threshold",
"weeklyThresholdDescription": "Switch when weekly usage reaches this level (recommended: 99%)",
"reactiveRecovery": "Reactive Recovery",
"reactiveDescription": "Auto-swap when unexpected rate limit is hit",
"autoSwitchOnAuthFailure": "Auto-switch on auth failure",
"autoSwitchOnAuthFailureDescription": "Automatically switch to another authenticated account when authentication fails"
"reactiveDescription": "Auto-swap when unexpected rate limit is hit"
},
"priority": {
"title": "Account Priority Order",
@@ -597,8 +595,6 @@
"needsAuth": "Not authenticated",
"duplicateUsage": "Duplicate usage detected",
"duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.",
"needsReauth": "Needs re-auth",
"needsReauthHint": "This profile's refresh token is invalid. Click to re-authenticate.",
"sessionUsage": "Session usage (5-hour window)",
"weeklyUsage": "Weekly usage (7-day window)",
"oauthSection": "Claude Accounts (cycle through first)",
@@ -106,8 +106,7 @@
"optional": "Optionnel",
"required": "Requis",
"dismiss": "Ignorer",
"important": "Important",
"orphaned": "(orphelin)"
"important": "Important"
},
"selection": {
"select": "Sélectionner",
@@ -475,7 +474,15 @@
"window5Hour": "Fenêtre de 5 heures",
"window7Day": "Fenêtre de 7 jours",
"window5HoursQuota": "Quota de 5 heures",
"windowMonthlyToolsQuota": "Quota mensuel d'outils"
"windowMonthlyToolsQuota": "Quota mensuel d'outils",
"otherAccounts": "Autres comptes",
"next": "Suivant",
"weeklyLimitReached": "Limite hebdomadaire atteinte",
"sessionLimitReached": "Limite de session atteinte",
"notAuthenticated": "Non authentifié",
"sessionShort": "Utilisation session 5 heures",
"weeklyShort": "Utilisation hebdomadaire 7 jours",
"swap": "Changer"
},
"oauth": {
"enterCode": "Saisie manuelle du code (secours)",
@@ -577,9 +577,7 @@
"weeklyThreshold": "Seuil d'utilisation hebdomadaire",
"weeklyThresholdDescription": "Basculer lorsque l'utilisation hebdomadaire atteint ce niveau (recommandé: 99%)",
"reactiveRecovery": "Récupération réactive",
"reactiveDescription": "Auto-basculement en cas de limite de taux inattendue",
"autoSwitchOnAuthFailure": "Changement auto en cas d'échec d'auth",
"autoSwitchOnAuthFailureDescription": "Basculer automatiquement vers un autre compte authentifié en cas d'échec d'authentification"
"reactiveDescription": "Auto-basculement en cas de limite de taux inattendue"
},
"priority": {
"title": "Ordre de priorité des comptes",
@@ -597,8 +595,6 @@
"needsAuth": "Non authentifié",
"duplicateUsage": "Doublon détecté",
"duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.",
"needsReauth": "Réauth requise",
"needsReauthHint": "Le token de rafraîchissement de ce profil est invalide. Cliquez pour vous réauthentifier.",
"sessionUsage": "Utilisation de session (fenêtre de 5 heures)",
"weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)",
"oauthSection": "Comptes Claude (utilisés en premier)",
+48
View File
@@ -79,6 +79,54 @@ export interface ClaudeUsageSnapshot {
weeklyUsageLimit?: number;
}
/**
* Profile usage summary for multi-profile display
* Contains the essential data needed to rank and display profiles in the usage indicator
*/
export interface ProfileUsageSummary {
/** Profile ID */
profileId: string;
/** Profile name for display */
profileName: string;
/** Email address (from Keychain or profile) */
profileEmail?: string;
/** Session usage percentage (0-100) */
sessionPercent: number;
/** Weekly usage percentage (0-100) */
weeklyPercent: number;
/** ISO timestamp of when the session limit resets */
sessionResetTimestamp?: string;
/** ISO timestamp of when the weekly limit resets */
weeklyResetTimestamp?: string;
/** Whether this profile is authenticated */
isAuthenticated: boolean;
/** Whether this profile is currently rate limited */
isRateLimited: boolean;
/** Type of rate limit if limited */
rateLimitType?: 'session' | 'weekly';
/** Availability score (higher = more available, used for sorting) */
availabilityScore: number;
/** Whether this is the currently active profile */
isActive: boolean;
/** When this data was last fetched (ISO timestamp) */
lastFetchedAt?: string;
/** Error message if usage fetch failed */
fetchError?: string;
}
/**
* All profiles usage data for the usage indicator
* Emitted alongside the active profile's detailed snapshot
*/
export interface AllProfilesUsage {
/** Detailed snapshot for the active profile */
activeProfile: ClaudeUsageSnapshot;
/** Summary usage data for all profiles (sorted by availability, best first) */
allProfiles: ProfileUsageSummary[];
/** When this data was collected */
fetchedAt: Date;
}
/**
* Rate limit event recorded for a profile
*/
+2 -4
View File
@@ -356,10 +356,8 @@ export interface ElectronAPI {
// Usage Monitoring (Proactive Account Switching)
/** Request current usage snapshot */
requestUsageUpdate: () => Promise<IPCResult<ClaudeUsageSnapshot | null>>;
/** Request all profiles usage immediately (for startup/refresh)
* @param forceRefresh - If true, bypasses cache to get fresh data for all profiles
*/
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<AllProfilesUsage | null>>;
/** Request all profiles usage immediately (for startup/refresh) */
requestAllProfilesUsage: () => Promise<IPCResult<AllProfilesUsage | null>>;
/** Listen for usage data updates */
onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void;
/** Listen for proactive swap notifications */