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:
committed by
StillKnotKnown
co-authored by
Claude Opus 4.5
parent
f23d528c20
commit
2f2790b100
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user