diff --git a/CLAUDE.md b/CLAUDE.md index 4b931c10..88ab7f9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,6 +355,33 @@ memory.add_session_insight("Pattern: use React hooks for state") ## Development Guidelines +### No Time Estimates + +**CRITICAL: Never provide time estimates or predictions for how long tasks will take.** + +AI-assisted development dramatically changes implementation timelines, making traditional estimates misleading. Avoid: +- Week/day/hour estimates (e.g., "Week 1: Foundation", "This will take 2-3 days") +- Phrases like "quick fix", "simple change", "this should be fast" +- Roadmaps with time-based phases + +Instead: +- Focus on **what** needs to be done, not **when** +- Break work into actionable steps without duration predictions +- Use priority-based ordering (High Impact, Low Effort) rather than time-based phases +- Let users judge timing for themselves based on their context + +``` +// ❌ WRONG - Time-based roadmap +Week 1: Foundation +Week 2: Prevention over Detection +Week 3: Calibration + +// ✅ CORRECT - Priority-based ordering +Phase 1: Foundation (High Impact, Low Effort) +Phase 2: Prevention over Detection +Phase 3: Calibration +``` + ### Frontend Internationalization (i18n) **CRITICAL: Always use i18n translation keys for all user-facing text in the frontend.** diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index 0c459262..4ba4477d 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -96,6 +96,19 @@ vi.mock('../../main/python-env-manager', () => ({ getConfiguredPythonPath: vi.fn(() => DETECTED_PYTHON_CMD) })); +// Mock rate-limit-detector for getBestAvailableProfileEnv +vi.mock('../../main/rate-limit-detector', () => ({ + getBestAvailableProfileEnv: vi.fn(() => ({ + env: {}, + profileId: 'default', + profileName: 'Default', + wasSwapped: false + })), + getProfileEnv: vi.fn(() => ({})), + detectRateLimit: vi.fn(() => ({ isRateLimited: false })), + detectAuthFailure: vi.fn(() => ({ isAuthFailure: false })) +})); + // Auto-claude source path (for getAutoBuildSourcePath to find) let AUTO_CLAUDE_SOURCE: string; diff --git a/apps/frontend/src/main/__tests__/insights-config.test.ts b/apps/frontend/src/main/__tests__/insights-config.test.ts index ef3df618..c7b75195 100644 --- a/apps/frontend/src/main/__tests__/insights-config.test.ts +++ b/apps/frontend/src/main/__tests__/insights-config.test.ts @@ -14,7 +14,12 @@ vi.mock('electron', () => ({ })); vi.mock('../rate-limit-detector', () => ({ - getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' }) + getBestAvailableProfileEnv: () => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false + }) })); const mockGetApiProfileEnv = vi.fn(); diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 7ce87909..3001a762 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -479,4 +479,50 @@ export class AgentManager extends EventEmitter { return true; } + + // ============================================ + // Queue Routing Methods (Rate Limit Recovery) + // ============================================ + + /** + * Get running tasks grouped by profile + * Used by queue routing to determine profile load + */ + getRunningTasksByProfile(): { byProfile: Record; totalRunning: number } { + return this.state.getRunningTasksByProfile(); + } + + /** + * Assign a profile to a task + * Records which profile is being used for a task + */ + assignProfileToTask( + taskId: string, + profileId: string, + profileName: string, + reason: 'proactive' | 'reactive' | 'manual' + ): void { + this.state.assignProfileToTask(taskId, profileId, profileName, reason); + } + + /** + * Get the profile assignment for a task + */ + getTaskProfileAssignment(taskId: string): { profileId: string; profileName: string; reason: string } | undefined { + return this.state.getTaskProfileAssignment(taskId); + } + + /** + * Update the session ID for a task (for session resume) + */ + updateTaskSession(taskId: string, sessionId: string): void { + this.state.updateTaskSession(taskId, sessionId); + } + + /** + * Get the session ID for a task + */ + getTaskSessionId(taskId: string): string | undefined { + return this.state.getTaskSessionId(taskId); + } } diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index e66423c3..6df08b91 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -84,7 +84,12 @@ vi.mock('../services/profile', () => ({ })); vi.mock('../rate-limit-detector', () => ({ - getProfileEnv: vi.fn(() => ({})), + getBestAvailableProfileEnv: vi.fn(() => ({ + env: {}, + profileId: 'default', + profileName: 'Default', + wasSwapped: false + })), detectRateLimit: vi.fn(() => ({ isRateLimited: false })), createSDKRateLimitInfo: vi.fn(), detectAuthFailure: vi.fn(() => ({ isAuthFailure: false })) @@ -287,8 +292,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); // Set OAuth token via getProfileEnv (existing flow) - vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({ - CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123' + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false }); await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); @@ -319,8 +327,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); // Set OAuth token - vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({ - CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-456' + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-456' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false }); await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); @@ -343,8 +354,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { // OAuth mode vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); - vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({ - CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-789' + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-789' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false }); await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); @@ -498,7 +512,12 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { ANTHROPIC_BASE_URL: 'https://api-profile.com' }; - vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue(profileEnv); + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: profileEnv, + profileId: 'default', + profileName: 'Default', + wasSwapped: false + }); vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(apiProfileEnv); await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], extraEnv, 'task-execution'); diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 27d79533..5bdac535 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -12,7 +12,7 @@ import { AgentState } from './agent-state'; import { AgentEvents } from './agent-events'; import { ProcessType, ExecutionProgressData } from './types'; import type { CompletablePhase } from '../../shared/constants/phase-protocol'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector'; +import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv, detectAuthFailure } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; import { projectStore } from '../project-store'; import { getClaudeProfileManager } from '../claude-profile-manager'; @@ -173,7 +173,9 @@ export class AgentProcessManager { private setupProcessEnvironment( extraEnv: Record ): NodeJS.ProcessEnv { - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; // Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.) // are available even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index d8c2ccd6..7ff4fc23 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -8,7 +8,7 @@ import { AgentProcessManager } from './agent-process'; import { RoadmapConfig } from './types'; import type { IdeationConfig, Idea } from '../../shared/types'; import { AUTO_BUILD_PATHS } from '../../shared/constants'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; +import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; import { getOAuthModeClearVars } from './env-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; @@ -326,8 +326,9 @@ export class AgentQueueManager { // Get combined environment variables const combinedEnv = this.processManager.getCombinedEnv(projectPath); - // Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default) - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; // Get active API profile environment variables const apiProfileEnv = await getAPIProfileEnv(); @@ -653,8 +654,9 @@ export class AgentQueueManager { // Get combined environment variables const combinedEnv = this.processManager.getCombinedEnv(projectPath); - // Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default) - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; // Get active API profile environment variables const apiProfileEnv = await getAPIProfileEnv(); diff --git a/apps/frontend/src/main/agent/agent-state.test.ts b/apps/frontend/src/main/agent/agent-state.test.ts new file mode 100644 index 00000000..03a9fa10 --- /dev/null +++ b/apps/frontend/src/main/agent/agent-state.test.ts @@ -0,0 +1,178 @@ +/** + * Tests for AgentState - Queue Routing functionality + * + * Tests the profile assignment tracking and running tasks by profile features. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AgentState } from './agent-state'; + +describe('AgentState - Queue Routing', () => { + let state: AgentState; + + beforeEach(() => { + state = new AgentState(); + }); + + describe('getRunningTasksByProfile', () => { + it('should return empty state when no processes running', () => { + const result = state.getRunningTasksByProfile(); + + expect(result.byProfile).toEqual({}); + expect(result.totalRunning).toBe(0); + }); + + it('should group tasks by profile', () => { + // Add mock processes + state.addProcess('task-1', { pid: 1001 } as any); + state.addProcess('task-2', { pid: 1002 } as any); + state.addProcess('task-3', { pid: 1003 } as any); + + // Assign profiles + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.assignProfileToTask('task-2', 'profile-1', 'Profile 1', 'proactive'); + state.assignProfileToTask('task-3', 'profile-2', 'Profile 2', 'proactive'); + + const result = state.getRunningTasksByProfile(); + + expect(result.byProfile['profile-1']).toHaveLength(2); + expect(result.byProfile['profile-2']).toHaveLength(1); + expect(result.totalRunning).toBe(3); + }); + + it('should use default profile for unassigned tasks', () => { + // Add process without profile assignment + state.addProcess('task-1', { pid: 1001 } as any); + + const result = state.getRunningTasksByProfile(); + + expect(result.byProfile['default']).toContain('task-1'); + expect(result.totalRunning).toBe(1); + }); + }); + + describe('assignProfileToTask', () => { + it('should assign profile to task', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Test Profile', 'proactive'); + + const assignment = state.getTaskProfileAssignment('task-1'); + + expect(assignment).toBeDefined(); + expect(assignment?.profileId).toBe('profile-1'); + expect(assignment?.profileName).toBe('Test Profile'); + expect(assignment?.reason).toBe('proactive'); + }); + + it('should preserve session ID when reassigning profile', () => { + // Initial assignment + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.updateTaskSession('task-1', 'session-abc'); + + // Reassign to different profile + state.assignProfileToTask('task-1', 'profile-2', 'Profile 2', 'reactive'); + + const assignment = state.getTaskProfileAssignment('task-1'); + expect(assignment?.profileId).toBe('profile-2'); + expect(assignment?.sessionId).toBe('session-abc'); + }); + + it('should support different assignment reasons', () => { + state.assignProfileToTask('task-1', 'p1', 'P1', 'proactive'); + state.assignProfileToTask('task-2', 'p2', 'P2', 'reactive'); + state.assignProfileToTask('task-3', 'p3', 'P3', 'manual'); + + expect(state.getTaskProfileAssignment('task-1')?.reason).toBe('proactive'); + expect(state.getTaskProfileAssignment('task-2')?.reason).toBe('reactive'); + expect(state.getTaskProfileAssignment('task-3')?.reason).toBe('manual'); + }); + }); + + describe('getTaskProfileAssignment', () => { + it('should return undefined for non-existent task', () => { + const assignment = state.getTaskProfileAssignment('non-existent'); + expect(assignment).toBeUndefined(); + }); + }); + + describe('updateTaskSession', () => { + it('should update session ID for existing assignment', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.updateTaskSession('task-1', 'session-123'); + + const assignment = state.getTaskProfileAssignment('task-1'); + expect(assignment?.sessionId).toBe('session-123'); + }); + + it('should create minimal assignment if none exists', () => { + // Update session without prior assignment + state.updateTaskSession('task-1', 'session-456'); + + const assignment = state.getTaskProfileAssignment('task-1'); + expect(assignment).toBeDefined(); + expect(assignment?.sessionId).toBe('session-456'); + expect(assignment?.profileId).toBe('unknown'); + }); + + it('should create assignment with provided profile info', () => { + state.updateTaskSession('task-1', 'session-789', { + profileId: 'my-profile', + profileName: 'My Profile' + }); + + const assignment = state.getTaskProfileAssignment('task-1'); + expect(assignment).toBeDefined(); + expect(assignment?.sessionId).toBe('session-789'); + expect(assignment?.profileId).toBe('my-profile'); + expect(assignment?.profileName).toBe('My Profile'); + }); + }); + + describe('getTaskSessionId', () => { + it('should return session ID if set', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.updateTaskSession('task-1', 'session-abc'); + + expect(state.getTaskSessionId('task-1')).toBe('session-abc'); + }); + + it('should return undefined if no session set', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + + expect(state.getTaskSessionId('task-1')).toBeUndefined(); + }); + + it('should return undefined for non-existent task', () => { + expect(state.getTaskSessionId('non-existent')).toBeUndefined(); + }); + }); + + describe('clearTaskProfileAssignment', () => { + it('should clear profile assignment for task', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.clearTaskProfileAssignment('task-1'); + + expect(state.getTaskProfileAssignment('task-1')).toBeUndefined(); + }); + + it('should not affect other tasks', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.assignProfileToTask('task-2', 'profile-2', 'Profile 2', 'proactive'); + + state.clearTaskProfileAssignment('task-1'); + + expect(state.getTaskProfileAssignment('task-2')).toBeDefined(); + }); + }); + + describe('clear', () => { + it('should clear all profile assignments', () => { + state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); + state.assignProfileToTask('task-2', 'profile-2', 'Profile 2', 'proactive'); + + state.clear(); + + expect(state.getTaskProfileAssignment('task-1')).toBeUndefined(); + expect(state.getTaskProfileAssignment('task-2')).toBeUndefined(); + }); + }); +}); diff --git a/apps/frontend/src/main/agent/agent-state.ts b/apps/frontend/src/main/agent/agent-state.ts index d0781212..9c571679 100644 --- a/apps/frontend/src/main/agent/agent-state.ts +++ b/apps/frontend/src/main/agent/agent-state.ts @@ -1,5 +1,15 @@ import { AgentProcess } from './types'; +/** + * Profile assignment for a task + */ +interface TaskProfileAssignment { + profileId: string; + profileName: string; + reason: 'proactive' | 'reactive' | 'manual'; + sessionId?: string; +} + /** * Agent state tracking and process map management */ @@ -8,6 +18,9 @@ export class AgentState { private killedSpawnIds: Set = new Set(); private spawnCounter: number = 0; + // Queue routing state (rate limit recovery) + private taskProfileAssignments: Map = new Map(); + /** * Generate a unique spawn ID */ @@ -84,5 +97,97 @@ export class AgentState { clear(): void { this.processes.clear(); this.killedSpawnIds.clear(); + this.taskProfileAssignments.clear(); + } + + // ============================================ + // Queue Routing Methods (Rate Limit Recovery) + // ============================================ + + /** + * Get running tasks grouped by profile + */ + getRunningTasksByProfile(): { byProfile: Record; totalRunning: number } { + const byProfile: Record = {}; + let totalRunning = 0; + + for (const [taskId] of this.processes) { + const assignment = this.taskProfileAssignments.get(taskId); + const profileId = assignment?.profileId || 'default'; + + if (!byProfile[profileId]) { + byProfile[profileId] = []; + } + byProfile[profileId].push(taskId); + totalRunning++; + } + + return { byProfile, totalRunning }; + } + + /** + * Assign a profile to a task + */ + assignProfileToTask( + taskId: string, + profileId: string, + profileName: string, + reason: 'proactive' | 'reactive' | 'manual' + ): void { + const existing = this.taskProfileAssignments.get(taskId); + this.taskProfileAssignments.set(taskId, { + profileId, + profileName, + reason, + sessionId: existing?.sessionId // Preserve session ID if exists + }); + } + + /** + * Get the profile assignment for a task + */ + getTaskProfileAssignment(taskId: string): TaskProfileAssignment | undefined { + return this.taskProfileAssignments.get(taskId); + } + + /** + * Update the session ID for a task + * + * @param taskId - The task ID + * @param sessionId - The Claude SDK session ID + * @param profileInfo - Optional profile info when creating a new assignment + */ + updateTaskSession( + taskId: string, + sessionId: string, + profileInfo?: { profileId: string; profileName: string } + ): void { + const assignment = this.taskProfileAssignments.get(taskId); + if (assignment) { + assignment.sessionId = sessionId; + } else { + // Create a minimal assignment if none exists + // Use provided profile info or 'unknown' as a placeholder + this.taskProfileAssignments.set(taskId, { + profileId: profileInfo?.profileId ?? 'unknown', + profileName: profileInfo?.profileName ?? 'Unknown', + reason: 'proactive', + sessionId + }); + } + } + + /** + * Get the session ID for a task + */ + getTaskSessionId(taskId: string): string | undefined { + return this.taskProfileAssignments.get(taskId)?.sessionId; + } + + /** + * Clear profile assignment for a task (on task completion) + */ + clearTaskProfileAssignment(taskId: string): void { + this.taskProfileAssignments.delete(taskId); } } diff --git a/apps/frontend/src/main/changelog/generator.ts b/apps/frontend/src/main/changelog/generator.ts index 4c782413..a4c57b7d 100644 --- a/apps/frontend/src/main/changelog/generator.ts +++ b/apps/frontend/src/main/changelog/generator.ts @@ -11,7 +11,7 @@ import type { import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './formatter'; import { extractChangelog } from './parser'; import { getCommits, getBranchDiffCommits } from './git-integration'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; +import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from '../rate-limit-detector'; import { parsePythonCommand } from '../python-detector'; import { getAugmentedEnv } from '../env-utils'; import { isWindows } from '../platform'; @@ -251,12 +251,15 @@ export class ChangelogGenerator extends EventEmitter { // even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); - // Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR) - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; this.debug('Active profile environment', { hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR, - authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default') + authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default'), + wasSwapped: profileResult.wasSwapped, + selectedProfile: profileResult.profileName }); const spawnEnv: Record = { diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index 47acdcf5..cb8bb685 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import * as os from 'os'; import type { GitCommit } from '../../shared/types'; -import { getProfileEnv } from '../rate-limit-detector'; +import { getBestAvailableProfileEnv } from '../rate-limit-detector'; import { parsePythonCommand } from '../python-detector'; import { getAugmentedEnv } from '../env-utils'; import { isWindows, requiresShell } from '../platform'; @@ -233,8 +233,9 @@ except Exception as e: // even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); - // Get active Claude profile environment - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; const spawnEnv: Record = { ...augmentedEnv, diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 5cd8cb07..bfba08af 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -14,6 +14,7 @@ import { app } from 'electron'; import { join } from 'path'; import { mkdir } from 'fs/promises'; +import { homedir } from 'os'; import type { ClaudeProfile, ClaudeProfileSettings, @@ -43,12 +44,13 @@ import { getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl } from './claude-profile/profile-scorer'; import { - DEFAULT_CLAUDE_CONFIG_DIR, + CLAUDE_PROFILES_DIR, generateProfileId as generateProfileIdImpl, createProfileDirectory as createProfileDirectoryImpl, isProfileAuthenticated as isProfileAuthenticatedImpl, hasValidToken, - expandHomePath + expandHomePath, + getEmailFromConfigDir } from './claude-profile/profile-utils'; /** @@ -111,8 +113,6 @@ 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) { @@ -166,21 +166,31 @@ export class ClaudeProfileManager { /** * Create default profile data + * + * IMPORTANT: New profiles use isolated directories (~/.claude-profiles/{name}) + * to prevent interference with external Claude Code CLI usage. + * The profile name is used as the directory name (sanitized to lowercase). */ private createDefaultData(): ProfileStoreData { + // Use an isolated directory for the initial profile + // This prevents interference with external Claude Code CLI which uses ~/.claude + const initialProfileName = 'Primary'; + const sanitizedName = initialProfileName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const isolatedConfigDir = join(CLAUDE_PROFILES_DIR, sanitizedName); + const defaultProfile: ClaudeProfile = { - id: 'default', - name: 'Default', - configDir: DEFAULT_CLAUDE_CONFIG_DIR, - isDefault: true, - description: 'Default Claude configuration (~/.claude)', + id: sanitizedName, // Use sanitized name as ID (e.g., 'primary') + name: initialProfileName, + configDir: isolatedConfigDir, + isDefault: true, // First profile is the default + description: 'Primary Claude account', createdAt: new Date() }; return { version: 3, profiles: [defaultProfile], - activeProfileId: 'default', + activeProfileId: sanitizedName, autoSwitch: DEFAULT_AUTO_SWITCH_SETTINGS }; } @@ -483,21 +493,17 @@ export class ClaudeProfileManager { const profile = this.getActiveProfile(); const env: Record = {}; - // 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 + // All profiles now use explicit CLAUDE_CONFIG_DIR for isolation + // This prevents interference with external Claude Code CLI usage if (profile?.configDir) { // Expand ~ to home directory for the environment variable const expandedConfigDir = profile.configDir.startsWith('~') - ? profile.configDir.replace(/^~/, require('os').homedir()) + ? profile.configDir.replace(/^~/, homedir()) : profile.configDir; env.CLAUDE_CONFIG_DIR = expandedConfigDir; - console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); + } } else { console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name); } @@ -611,12 +617,19 @@ export class ClaudeProfileManager { } /** - * Get the best profile to switch to based on usage and rate limit status + * Get the best profile to switch to based on priority order and availability * Returns null if no good alternative is available + * + * Selection logic: + * 1. Respects user's configured account priority order + * 2. Filters by availability (authenticated, not rate-limited, below thresholds) + * 3. Returns first available profile in priority order + * 4. Falls back to "least bad" option if no profile meets all criteria */ getBestAvailableProfile(excludeProfileId?: string): ClaudeProfile | null { const settings = this.getAutoSwitchSettings(); - return getBestAvailableProfile(this.data.profiles, settings, excludeProfileId); + const priorityOrder = this.getAccountPriorityOrder(); + return getBestAvailableProfile(this.data.profiles, settings, excludeProfileId, priorityOrder); } /** @@ -629,7 +642,8 @@ export class ClaudeProfileManager { } const settings = this.getAutoSwitchSettings(); - return shouldProactivelySwitchImpl(profile, this.data.profiles, settings); + const priorityOrder = this.getAccountPriorityOrder(); + return shouldProactivelySwitchImpl(profile, this.data.profiles, settings, priorityOrder); } /** @@ -683,7 +697,14 @@ export class ClaudeProfileManager { } /** - * Get environment variables for invoking Claude with a specific profile + * Get environment variables for invoking Claude with a specific profile. + * + * IMPORTANT: Always returns CLAUDE_CONFIG_DIR for the profile, even for the default profile. + * This ensures that when we switch to a specific profile for rate limit recovery, + * we use that profile's exact configDir credentials, not just whatever happens to be + * at ~/.claude (which might belong to a different profile). + * + * The ~ path is expanded to the full home directory path. */ getProfileEnv(profileId: string): Record { const profile = this.getProfile(profileId); @@ -691,18 +712,28 @@ export class ClaudeProfileManager { return {}; } - // Only set CLAUDE_CONFIG_DIR if not using default - if (profile.isDefault) { - return {}; - } - - // Only set CLAUDE_CONFIG_DIR if configDir is defined + // If no configDir is defined, fall back to default if (!profile.configDir) { return {}; } + // Expand ~ to home directory for the environment variable + const expandedConfigDir = profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, require('os').homedir()) + : profile.configDir; + + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] getProfileEnv:', { + profileId, + profileName: profile.name, + isDefault: profile.isDefault, + configDir: profile.configDir, + expandedConfigDir + }); + } + return { - CLAUDE_CONFIG_DIR: profile.configDir + CLAUDE_CONFIG_DIR: expandedConfigDir }; } @@ -723,6 +754,46 @@ export class ClaudeProfileManager { getProfilesSortedByAvailability(): ClaudeProfile[] { return getProfilesSortedByAvailabilityImpl(this.data.profiles); } + + /** + * Get the list of profile IDs that were migrated from shared ~/.claude to isolated directories. + * These profiles need re-authentication since their credentials are in the old location. + */ + getMigratedProfileIds(): string[] { + return this.data.migratedProfileIds || []; + } + + /** + * Clear a profile from the migrated list after successful re-authentication. + * Called when the user completes re-authentication for a migrated profile. + * + * @param profileId - The profile ID to clear from the migrated list + */ + clearMigratedProfile(profileId: string): void { + if (!this.data.migratedProfileIds) { + return; + } + + this.data.migratedProfileIds = this.data.migratedProfileIds.filter(id => id !== profileId); + + // If list is empty, remove the property entirely + if (this.data.migratedProfileIds.length === 0) { + delete this.data.migratedProfileIds; + } + + this.save(); + console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId); + } + + /** + * Check if a profile was migrated and needs re-authentication. + * + * @param profileId - The profile ID to check + * @returns true if the profile was migrated and needs re-auth + */ + isProfileMigrated(profileId: string): boolean { + return this.data.migratedProfileIds?.includes(profileId) ?? false; + } } // Singleton instance and initialization promise diff --git a/apps/frontend/src/main/claude-profile/credential-utils.test.ts b/apps/frontend/src/main/claude-profile/credential-utils.test.ts index 0143e5bb..bf0b6c7f 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.test.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.test.ts @@ -210,8 +210,37 @@ describe('credential-utils', () => { vi.mocked(homedir).mockReturnValue('/home/testuser'); }); - it('should return credentials from .credentials.json', () => { + // 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 vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-secret-service-token', + email: 'secretservice@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-secret-service-token'); + expect(result.email).toBe('secretservice@example.com'); + 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', @@ -226,7 +255,7 @@ describe('credential-utils', () => { expect(result.error).toBeUndefined(); }); - it('should return null when credentials file not found', () => { + it('should return null when credentials file not found and Secret Service unavailable', () => { vi.mocked(existsSync).mockReturnValue(false); const result = getCredentialsFromKeychain(); @@ -237,7 +266,12 @@ describe('credential-utils', () => { it('should use custom configDir for credentials path', () => { const customConfigDir = '/home/user/.claude-profiles/work'; - vi.mocked(existsSync).mockReturnValue(true); + mockSecretServiceUnavailable(); + vi.mocked(existsSync).mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('secret-tool')) return false; + return true; // credentials file exists + }); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-custom-token', @@ -252,7 +286,7 @@ describe('credential-utils', () => { }); it('should handle emailAddress field (alternative email location)', () => { - vi.mocked(existsSync).mockReturnValue(true); + mockSecretServiceUnavailable(); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-test-token', @@ -266,7 +300,7 @@ describe('credential-utils', () => { }); it('should handle top-level email field', () => { - vi.mocked(existsSync).mockReturnValue(true); + mockSecretServiceUnavailable(); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-test-token', @@ -280,7 +314,7 @@ describe('credential-utils', () => { }); it('should handle file read permission errors', () => { - vi.mocked(existsSync).mockReturnValue(true); + mockSecretServiceUnavailable(); vi.mocked(readFileSync).mockImplementation(() => { throw new Error('EACCES: permission denied'); }); @@ -290,6 +324,30 @@ 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: 'fallback@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-fallback-token'); + expect(result.email).toBe('fallback@example.com'); + }); }); describe('getCredentialsFromKeychain (Windows)', () => { diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 32659755..070cc28a 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -4,7 +4,7 @@ * Provides functions to retrieve Claude Code OAuth tokens and email from * platform-specific secure storage: * - macOS: Keychain (via `security` command) - * - Linux: .credentials.json file in config directory + * - Linux: Secret Service API (via `secret-tool` command), with fallback to .credentials.json file * - Windows: Windows Credential Manager (via PowerShell) * * Supports both: @@ -17,8 +17,8 @@ import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; -import { existsSync, readFileSync } from 'fs'; -import { homedir } from 'os'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { homedir, userInfo } from 'os'; import { join } from 'path'; import { isMacOS, isWindows, isLinux } from '../platform'; @@ -36,6 +36,31 @@ function getTokenFingerprint(token: string | null | undefined): string { return token.slice(0, 8) + '...' + token.slice(-4); } +/** + * Escape a string for safe interpolation into PowerShell double-quoted strings. + * Escapes all PowerShell special characters to prevent injection attacks. + * + * @param str - The string to escape + * @returns The escaped string safe for PowerShell interpolation + */ +function escapePowerShellString(str: string): string { + return str + .replace(/`/g, '``') // Backtick is PowerShell's escape character - must be escaped first + .replace(/\$/g, '`$') // Dollar sign triggers variable expansion + .replace(/"/g, '`"'); // Double quotes end the string +} + +/** + * Encode a string to base64 for safe passing to PowerShell. + * This is the most secure way to pass arbitrary data to PowerShell scripts. + * + * @param str - The string to encode + * @returns Base64-encoded string + */ +function encodeBase64ForPowerShell(str: string): string { + return Buffer.from(str, 'utf-8').toString('base64'); +} + /** * Credentials retrieved from platform-specific secure storage */ @@ -48,6 +73,24 @@ export interface PlatformCredentials { // Legacy alias for backwards compatibility export type KeychainCredentials = PlatformCredentials; +/** + * Full OAuth credentials including refresh token and expiry info + * Used for token refresh operations + */ +export interface FullOAuthCredentials extends PlatformCredentials { + refreshToken: string | null; + expiresAt: number | null; // Unix timestamp in ms when access token expires + scopes: string[] | null; +} + +/** + * Result of updating credentials in the keychain/credential store + */ +export interface UpdateCredentialsResult { + success: boolean; + error?: string; +} + /** * Cache for credentials to avoid repeated blocking calls * Map key is the cache key (e.g., "macos:Claude Code-credentials" or "linux:/home/user/.claude") @@ -115,14 +158,29 @@ export function calculateConfigDirHash(configDir: string): string { /** * Get the Keychain service name for a config directory (macOS). * - * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name. + * All profiles use hash-based keychain entries for isolation. + * This prevents interference with external Claude Code CLI which uses + * "Claude Code-credentials" (no hash) for ~/.claude. + * + * @param configDir - CLAUDE_CONFIG_DIR path. Required for isolation. * @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506") */ export function getKeychainServiceName(configDir?: string): string { + // No configDir provided - this should not happen with isolated profiles + // Fall back to unhashed name for backwards compatibility during migration if (!configDir) { + console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback'); return 'Claude Code-credentials'; } - const hash = calculateConfigDirHash(configDir); + + // Normalize the configDir: expand ~ and resolve to absolute path + const normalizedConfigDir = configDir.startsWith('~') + ? join(homedir(), configDir.slice(1)) + : configDir; + + // ALL profiles now use hash-based keychain entries for isolation + // This prevents interference with external Claude Code CLI + const hash = calculateConfigDirHash(normalizedConfigDir); return `Claude Code-credentials-${hash}`; } @@ -189,6 +247,44 @@ function extractCredentials(data: { claudeAiOauth?: { accessToken?: string; emai return { token, email }; } +/** + * Extract full credentials including refresh token and expiry from validated credential data + */ +function extractFullCredentials(data: { + claudeAiOauth?: { + accessToken?: string; + email?: string; + emailAddress?: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + }; + email?: string +}): { + token: string | null; + email: string | null; + refreshToken: string | null; + expiresAt: number | null; + scopes: string[] | null; +} { + // Extract OAuth token from nested structure + const token = data?.claudeAiOauth?.accessToken || null; + + // Extract email (might be in different locations depending on Claude Code version) + const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email || null; + + // Extract refresh token + const refreshToken = data?.claudeAiOauth?.refreshToken || null; + + // Extract expiry timestamp (Unix timestamp in ms) + const expiresAt = data?.claudeAiOauth?.expiresAt || null; + + // Extract scopes (array of strings) + const scopes = data?.claudeAiOauth?.scopes || null; + + return { token, email, refreshToken, expiresAt, scopes }; +} + /** * Validate token format * Use 'sk-ant-' prefix to support future token format versions (oat02, oat03, etc.) @@ -197,6 +293,88 @@ function isValidTokenFormat(token: string): boolean { return token.startsWith('sk-ant-'); } +// ============================================================================= +// Platform-Specific Credential Reading Helpers (Shared Implementation) +// ============================================================================= + +/** + * Execute a credential read operation with platform-specific executable. + * Shared helper to reduce code duplication across macOS, Linux, and Windows. + * + * @param executablePath - Path to the security/secret-tool/powershell executable + * @param args - Arguments to pass to the executable + * @param timeout - Timeout in milliseconds + * @param identifier - Identifier for logging (e.g., "macOS:serviceName", "Linux:attribute") + * @returns The raw output string or null if not found + */ +function executeCredentialRead( + executablePath: string, + args: string[], + timeout: number, + identifier: string +): string | null { + try { + const result = execFileSync(executablePath, args, { + encoding: 'utf-8', + timeout, + windowsHide: true, + }); + return result.trim(); + } catch (error) { + // Handle expected "not found" errors (macOS exit code 44, Linux/Windows non-zero exit) + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status: number }).status; + if (status === 44) { + // macOS: errSecItemNotFound + return null; + } + } + // Check for "not found" in error message (Linux/Windows) + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('not found') || errorMessage.includes('exit code')) { + return null; + } + // Re-throw unexpected errors + throw error; + } +} + +/** + * Parse and validate credential JSON from platform storage. + * Shared helper to reduce code duplication across platforms. + * + * @param credentialsJson - Raw JSON string from credential store + * @param identifier - Identifier for logging (e.g., "macOS:serviceName") + * @param extractFn - Function to extract credentials (basic or full) + * @returns Extracted credentials or null values if invalid + */ +function parseCredentialJson( + credentialsJson: string | null, + identifier: string, + extractFn: (data: any) => T +): T { + if (!credentialsJson) { + return extractFn({}) as T; + } + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`); + return extractFn({}) as T; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`); + return extractFn({}) as T; + } + + return extractFn(data); +} + // ============================================================================= // macOS Keychain Implementation // ============================================================================= @@ -246,44 +424,20 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } try { - // Query macOS Keychain for Claude Code credentials - const result = execFileSync( + // Query macOS Keychain for Claude Code credentials using shared helper + const credentialsJson = executeCredentialRead( securityPath, ['find-generic-password', '-s', serviceName, '-w'], - { - encoding: 'utf-8', - timeout: MACOS_KEYCHAIN_TIMEOUT_MS, - windowsHide: true, - } + MACOS_KEYCHAIN_TIMEOUT_MS, + `macOS:${serviceName}` ); - const credentialsJson = result.trim(); - if (!credentialsJson) { - const emptyResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: emptyResult, timestamp: now }); - return emptyResult; - } - - // Parse JSON response - let data: unknown; - try { - data = JSON.parse(credentialsJson); - } catch { - console.warn('[CredentialUtils:macOS] Failed to parse Keychain JSON for service:', serviceName); - const errorResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn('[CredentialUtils:macOS] Invalid Keychain data structure for service:', serviceName); - const invalidResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - const { token, email } = extractCredentials(data); + // Parse and validate using shared helper + const { token, email } = parseCredentialJson( + credentialsJson, + `macOS:${serviceName}`, + extractCredentials + ); // Validate token format if present if (token && !isValidTokenFormat(token)) { @@ -306,13 +460,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } return credentials; } catch (error) { - // Check for exit code 44 (errSecItemNotFound) which indicates item not found - if (error && typeof error === 'object' && 'status' in error && error.status === 44) { - const notFoundResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } - + // Unexpected error (executeCredentialRead already handles "not found" cases) const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage); const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` }; @@ -323,7 +471,162 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } // ============================================================================= -// Linux Credentials File Implementation +// Linux Secret Service Implementation +// ============================================================================= + +/** + * Timeout for secret-tool commands (5 seconds) + */ +const LINUX_SECRET_TOOL_TIMEOUT_MS = 5000; + +/** + * Find secret-tool executable path on Linux + * secret-tool is part of libsecret-tools package + */ +function findSecretToolPath(): string | null { + const candidatePaths = [ + '/usr/bin/secret-tool', + '/bin/secret-tool', + '/usr/local/bin/secret-tool', + ]; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** + * Get the Secret Service attribute value for a config directory. + * For default profile, uses "claude-code". + * For custom profiles, uses "claude-code-{hash}" where hash is first 8 chars of SHA256. + */ +function getSecretServiceAttribute(configDir?: string): string { + if (!configDir) { + return 'claude-code'; + } + // For custom config dirs, create a hashed attribute to avoid conflicts + const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8); + return `claude-code-${hash}`; +} + +/** + * Retrieve credentials from Linux Secret Service using secret-tool CLI. + * + * Claude Code stores credentials in Secret Service with: + * - Label: "Claude Code-credentials" + * - Attributes: {application: "claude-code"} + * - Secret: JSON string with claudeAiOauth.accessToken + */ +function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh = false): PlatformCredentials { + const attribute = getSecretServiceAttribute(configDir); + const cacheKey = `linux-secret:${attribute}`; + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', { + attribute, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Find secret-tool executable + const secretToolPath = findSecretToolPath(); + if (!secretToolPath) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage'); + } + // Return a special result indicating Secret Service is unavailable + return { token: null, email: null, error: 'secret-tool not found' }; + } + + try { + // Query Secret Service for credentials using shared helper + const credentialsJson = executeCredentialRead( + secretToolPath, + ['lookup', 'application', attribute], + LINUX_SECRET_TOOL_TIMEOUT_MS, + `Linux:SecretService:${attribute}` + ); + + // Parse and validate using shared helper + const { token, email } = parseCredentialJson( + credentialsJson, + `Linux:SecretService:${attribute}`, + extractCredentials + ); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', { + attribute, + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + // Unexpected error (executeCredentialRead already handles "not found" cases) + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage); + // Return error to trigger fallback to file storage + return { token: null, email: null, error: `Secret Service access failed: ${errorMessage}` }; + } +} + +/** + * Retrieve credentials from Linux - tries Secret Service first, falls back to file + */ +function getCredentialsFromLinux(configDir?: string, forceRefresh = false): PlatformCredentials { + const isDebug = process.env.DEBUG === 'true'; + + // Try Secret Service first (preferred secure storage) + const secretServiceResult = getCredentialsFromLinuxSecretService(configDir, forceRefresh); + + // If we got a token from Secret Service, use it + if (secretServiceResult.token) { + return secretServiceResult; + } + + // If Secret Service had an error (not just "not found"), log it and try file fallback + if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) { + if (isDebug) { + console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error); + } + } + + // Fall back to file-based storage + return getCredentialsFromLinuxFile(configDir, forceRefresh); +} + +// ============================================================================= +// Linux Credentials File Implementation (Fallback) // ============================================================================= /** @@ -335,7 +638,7 @@ function getLinuxCredentialsPath(configDir?: string): string { } /** - * Retrieve credentials from Linux .credentials.json file + * Retrieve credentials from Linux .credentials.json file (fallback when Secret Service unavailable) */ function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials { const credentialsPath = getLinuxCredentialsPath(configDir); @@ -507,7 +810,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef $credPtr = [IntPtr]::Zero # CRED_TYPE_GENERIC = 1 - $success = [Win32.Credential]::CredRead("${targetName.replace(/"/g, '`"')}", 1, 0, [ref]$credPtr) + $success = [Win32.Credential]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) if ($success) { try { @@ -540,36 +843,14 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef } ); - const credentialsJson = result.trim(); - if (!credentialsJson) { - if (isDebug) { - console.warn('[CredentialUtils:Windows] Credential not found for target:', targetName); - } - const notFoundResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } + const credentialsJson = result.trim() || null; - // Parse JSON response - let data: unknown; - try { - data = JSON.parse(credentialsJson); - } catch { - console.warn('[CredentialUtils:Windows] Failed to parse credential JSON for target:', targetName); - const errorResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn('[CredentialUtils:Windows] Invalid credential data structure for target:', targetName); - const invalidResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - const { token, email } = extractCredentials(data); + // Parse and validate using shared helper + const { token, email } = parseCredentialJson( + credentialsJson, + `Windows:${targetName}`, + extractCredentials + ); // Validate token format if present if (token && !isValidTokenFormat(token)) { @@ -647,7 +928,7 @@ export function getCredentialsFromKeychain(configDir?: string, forceRefresh = fa } if (isLinux()) { - return getCredentialsFromLinuxFile(configDir, forceRefresh); + return getCredentialsFromLinux(configDir, forceRefresh); } if (isWindows()) { @@ -673,11 +954,13 @@ export function clearKeychainCache(configDir?: string): void { if (configDir) { // Clear cache for this specific configDir on all platforms const macOSKey = `macos:${getKeychainServiceName(configDir)}`; - const linuxKey = `linux:${getLinuxCredentialsPath(configDir)}`; + const linuxSecretKey = `linux-secret:${getSecretServiceAttribute(configDir)}`; + const linuxFileKey = `linux:${getLinuxCredentialsPath(configDir)}`; const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`; credentialCache.delete(macOSKey); - credentialCache.delete(linuxKey); + credentialCache.delete(linuxSecretKey); + credentialCache.delete(linuxFileKey); credentialCache.delete(windowsKey); } else { credentialCache.clear(); @@ -688,3 +971,772 @@ export function clearKeychainCache(configDir?: string): void { * Alias for clearKeychainCache for semantic clarity */ export const clearCredentialCache = clearKeychainCache; + +// ============================================================================= +// Extended Credential Operations (Token Refresh Support) +// ============================================================================= + +/** + * Retrieve full credentials (including refresh token) from macOS Keychain + */ +function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCredentials { + const serviceName = getKeychainServiceName(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Locate the security executable + let securityPath: string | null = null; + const candidatePaths = ['/usr/bin/security', '/bin/security']; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + securityPath = candidate; + break; + } + } + + if (!securityPath) { + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'macOS security command not found' }; + } + + try { + // Query macOS Keychain for Claude Code credentials using shared helper + const credentialsJson = executeCredentialRead( + securityPath, + ['find-generic-password', '-s', serviceName, '-w'], + MACOS_KEYCHAIN_TIMEOUT_MS, + `macOS:Full:${serviceName}` + ); + + // Parse and validate using shared helper + const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + credentialsJson, + `macOS:Full:${serviceName}`, + extractFullCredentials + ); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName); + return { token: null, email, refreshToken, expiresAt, scopes }; + } + + if (isDebug) { + console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, { + hasToken: !!token, + hasEmail: !!email, + hasRefreshToken: !!refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + tokenFingerprint: getTokenFingerprint(token) + }); + } + return { token, email, refreshToken, expiresAt, scopes }; + } catch (error) { + // Unexpected error (executeCredentialRead already handles "not found" cases) + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Keychain access failed: ${errorMessage}` }; + } +} + +/** + * Retrieve full credentials (including refresh token) from Linux Secret Service + */ +function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuthCredentials { + const attribute = getSecretServiceAttribute(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Find secret-tool executable + const secretToolPath = findSecretToolPath(); + if (!secretToolPath) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found'); + } + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'secret-tool not found' }; + } + + try { + // Query Secret Service for credentials using shared helper + const credentialsJson = executeCredentialRead( + secretToolPath, + ['lookup', 'application', attribute], + LINUX_SECRET_TOOL_TIMEOUT_MS, + `Linux:SecretService:Full:${attribute}` + ); + + // Parse and validate using shared helper + const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + credentialsJson, + `Linux:SecretService:Full:${attribute}`, + extractFullCredentials + ); + + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute); + return { token: null, email, refreshToken, expiresAt, scopes }; + } + + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', { + attribute, + hasToken: !!token, + hasEmail: !!email, + hasRefreshToken: !!refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + tokenFingerprint: getTokenFingerprint(token) + }); + } + return { token, email, refreshToken, expiresAt, scopes }; + } catch (error) { + // Unexpected error (executeCredentialRead already handles "not found" cases) + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Secret Service access failed: ${errorMessage}` }; + } +} + +/** + * Retrieve full credentials from Linux - tries Secret Service first, falls back to file + */ +function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials { + const isDebug = process.env.DEBUG === 'true'; + + // Try Secret Service first + const secretServiceResult = getFullCredentialsFromLinuxSecretService(configDir); + + if (secretServiceResult.token) { + return secretServiceResult; + } + + if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error); + } + } + + // Fall back to file-based storage + return getFullCredentialsFromLinuxFile(configDir); +} + +/** + * Retrieve full credentials (including refresh token) from Linux .credentials.json file (fallback) + */ +function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentials { + const credentialsPath = getLinuxCredentialsPath(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Defense-in-depth: Validate credentials path is within expected boundaries + if (!isValidCredentialsPath(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:Full] Invalid credentials path rejected:', { credentialsPath }); + } + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'Invalid credentials path' }; + } + + // Check if credentials file exists + if (!existsSync(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:Full] Credentials file not found:', credentialsPath); + } + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.warn('[CredentialUtils:Linux:Full] Failed to parse credentials JSON:', credentialsPath); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:Linux:Full] Invalid credentials data structure:', credentialsPath); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + } + + const { token, email, refreshToken, expiresAt, scopes } = extractFullCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Linux:Full] Invalid token format in:', credentialsPath); + return { token: null, email, refreshToken, expiresAt, scopes }; + } + + if (isDebug) { + console.warn('[CredentialUtils:Linux:Full] Retrieved full credentials from file:', credentialsPath, { + hasToken: !!token, + hasEmail: !!email, + hasRefreshToken: !!refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + tokenFingerprint: getTokenFingerprint(token) + }); + } + return { token, email, refreshToken, expiresAt, scopes }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Linux:Full] Failed to read credentials file:', credentialsPath, errorMessage); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Failed to read credentials: ${errorMessage}` }; + } +} + +/** + * Retrieve full credentials (including refresh token) from Windows Credential Manager + */ +function getFullCredentialsFromWindowsCredentialManager(configDir?: string): FullOAuthCredentials { + const targetName = getWindowsCredentialTarget(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Defense-in-depth: Validate target name format before using in PowerShell + if (!isValidTargetName(targetName)) { + const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'Invalid credential target name format' }; + if (isDebug) { + console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName }); + } + return invalidResult; + } + + // Find PowerShell executable + const psPath = findPowerShellPath(); + if (!psPath) { + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'PowerShell not found' }; + } + + try { + // PowerShell script to read from Credential Manager (same as basic credentials) + const psScript = ` + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.Runtime.WindowsRuntime + + # Use CredRead from advapi32.dll to read generic credentials + $sig = @' + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CredFree(IntPtr cred); +'@ + Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential + + $credPtr = [IntPtr]::Zero + # CRED_TYPE_GENERIC = 1 + $success = [Win32.Credential]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) + + if ($success) { + try { + $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL')) + + # Read the credential blob (password field) + $blobSize = $cred.CredentialBlobSize + if ($blobSize -gt 0) { + $blob = [byte[]]::new($blobSize) + [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $blob, 0, $blobSize) + $password = [System.Text.Encoding]::Unicode.GetString($blob) + Write-Output $password + } + } finally { + [Win32.Credential]::CredFree($credPtr) | Out-Null + } + } else { + # Credential not found - this is expected if user hasn't authenticated + Write-Output "" + } + `; + + const result = execFileSync( + psPath, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript], + { + encoding: 'utf-8', + timeout: WINDOWS_CREDMAN_TIMEOUT_MS, + windowsHide: true, + } + ); + + const credentialsJson = result.trim() || null; + + // Parse and validate using shared helper + const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + credentialsJson, + `Windows:Full:${targetName}`, + extractFullCredentials + ); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName); + return { token: null, email, refreshToken, expiresAt, scopes }; + } + + if (isDebug) { + console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, { + hasToken: !!token, + hasEmail: !!email, + hasRefreshToken: !!refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + tokenFingerprint: getTokenFingerprint(token) + }); + } + return { token, email, refreshToken, expiresAt, scopes }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Credential Manager access failed: ${errorMessage}` }; + } +} + +/** + * Get full credentials including refresh token and expiry from platform-specific secure storage. + * This is an extended version of getCredentialsFromKeychain that returns all credential data + * needed for token refresh operations. + * + * @param configDir - Optional config directory for profile-specific credentials + * @returns Full credentials including refresh token and expiry information + */ +export function getFullCredentialsFromKeychain(configDir?: string): FullOAuthCredentials { + if (isMacOS()) { + return getFullCredentialsFromMacOSKeychain(configDir); + } + + if (isLinux()) { + return getFullCredentialsFromLinux(configDir); + } + + if (isWindows()) { + return getFullCredentialsFromWindowsCredentialManager(configDir); + } + + // Unknown platform - return empty + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Unsupported platform: ${process.platform}` }; +} + +/** + * Update credentials in macOS Keychain with new tokens + */ +function updateMacOSKeychainCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const serviceName = getKeychainServiceName(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Locate the security executable + let securityPath: string | null = null; + const candidatePaths = ['/usr/bin/security', '/bin/security']; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + securityPath = candidate; + break; + } + } + + if (!securityPath) { + return { success: false, error: 'macOS security command not found' }; + } + + try { + // Read existing credentials to preserve email + const existing = getFullCredentialsFromMacOSKeychain(configDir); + + // Build new credential JSON with all fields + const newCredentialData = { + claudeAiOauth: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + scopes: credentials.scopes || existing.scopes || [], + email: existing.email || undefined, + emailAddress: existing.email || undefined + }, + email: existing.email || undefined + }; + + const credentialsJson = JSON.stringify(newCredentialData); + + // CRITICAL FIX: The -U flag only updates if the account name matches exactly. + // Claude Code CLI stores credentials with the system username as the account, + // but we were using 'claude-ai-oauth'. This mismatch caused updates to create + // a NEW entry instead of updating the existing one, leading to stale tokens. + // + // Solution: Delete any existing entry first, then add fresh. + // This ensures we don't end up with multiple entries with different account names. + + // Step 1: Delete existing entry (ignore errors if not found) + try { + execFileSync( + securityPath, + ['delete-generic-password', '-s', serviceName], + { + encoding: 'utf-8', + timeout: MACOS_KEYCHAIN_TIMEOUT_MS, + windowsHide: true, + } + ); + if (isDebug) { + console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName); + } + } catch { + // Entry didn't exist - that's fine, we'll create it + if (isDebug) { + console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName); + } + } + + // Step 2: Add new entry with system username as account name + // Claude Code CLI uses the system username, so we must match that for compatibility + const accountName = userInfo().username; + execFileSync( + securityPath, + ['add-generic-password', '-s', serviceName, '-a', accountName, '-w', credentialsJson], + { + encoding: 'utf-8', + timeout: MACOS_KEYCHAIN_TIMEOUT_MS, + windowsHide: true, + } + ); + + if (isDebug) { + console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName); + } + + // Clear cached credentials to ensure fresh values are read + clearCredentialCache(configDir); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[CredentialUtils:macOS:Update] Failed to update Keychain credentials:', errorMessage); + return { success: false, error: `Keychain update failed: ${errorMessage}` }; + } +} + +/** + * Update credentials in Linux Secret Service with new tokens + */ +function updateLinuxSecretServiceCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const attribute = getSecretServiceAttribute(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Find secret-tool executable + const secretToolPath = findSecretToolPath(); + if (!secretToolPath) { + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found'); + } + return { success: false, error: 'secret-tool not found' }; + } + + try { + // Read existing credentials to preserve email + const existing = getFullCredentialsFromLinuxSecretService(configDir); + + // Build new credential JSON with all fields + const newCredentialData = { + claudeAiOauth: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + scopes: credentials.scopes || existing.scopes || [], + email: existing.email || undefined, + emailAddress: existing.email || undefined + }, + email: existing.email || undefined + }; + + const credentialsJson = JSON.stringify(newCredentialData); + + // Use secret-tool store to update credentials + // secret-tool store --label="Claude Code-credentials" application claude-code + execFileSync( + secretToolPath, + ['store', '--label=Claude Code-credentials', 'application', attribute], + { + encoding: 'utf-8', + timeout: LINUX_SECRET_TOOL_TIMEOUT_MS, + input: credentialsJson, + windowsHide: true, + } + ); + + if (isDebug) { + console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute); + } + + // Clear cached credentials to ensure fresh values are read + clearCredentialCache(configDir); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[CredentialUtils:Linux:SecretService:Update] Failed to update Secret Service credentials:', errorMessage); + return { success: false, error: `Secret Service update failed: ${errorMessage}` }; + } +} + +/** + * Update credentials in Linux - tries Secret Service first, falls back to file + */ +function updateLinuxCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const isDebug = process.env.DEBUG === 'true'; + + // Try Secret Service first + const secretToolPath = findSecretToolPath(); + if (secretToolPath) { + const secretServiceResult = updateLinuxSecretServiceCredentials(configDir, credentials); + if (secretServiceResult.success) { + return secretServiceResult; + } + if (isDebug) { + console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error); + } + } + + // Fall back to file-based storage + return updateLinuxFileCredentials(configDir, credentials); +} + +/** + * Update credentials in Linux .credentials.json file with new tokens (fallback) + */ +function updateLinuxFileCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const credentialsPath = getLinuxCredentialsPath(configDir); + const isDebug = process.env.DEBUG === 'true'; + + + // Defense-in-depth: Validate credentials path + if (!isValidCredentialsPath(credentialsPath)) { + return { success: false, error: 'Invalid credentials path' }; + } + + try { + // Read existing credentials to preserve email and other fields + const existing = getFullCredentialsFromLinuxFile(configDir); + + // Build new credential JSON with all fields + const newCredentialData = { + claudeAiOauth: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + scopes: credentials.scopes || existing.scopes || [], + email: existing.email || undefined, + emailAddress: existing.email || undefined + }, + email: existing.email || undefined + }; + + const credentialsJson = JSON.stringify(newCredentialData, null, 2); + + // Write to file with secure permissions (0600) + writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' }); + + if (isDebug) { + console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath); + } + + // Clear cached credentials to ensure fresh values are read + clearCredentialCache(configDir); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[CredentialUtils:Linux:Update] Failed to update credentials file:', errorMessage); + return { success: false, error: `File update failed: ${errorMessage}` }; + } +} + +/** + * Update credentials in Windows Credential Manager with new tokens + */ +function updateWindowsCredentialManagerCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const targetName = getWindowsCredentialTarget(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Defense-in-depth: Validate target name format + if (!isValidTargetName(targetName)) { + return { success: false, error: 'Invalid credential target name format' }; + } + + // Find PowerShell executable + const psPath = findPowerShellPath(); + if (!psPath) { + return { success: false, error: 'PowerShell not found' }; + } + + try { + // Read existing credentials to preserve email + const existing = getFullCredentialsFromWindowsCredentialManager(configDir); + + // Build new credential JSON with all fields + const newCredentialData = { + claudeAiOauth: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + scopes: credentials.scopes || existing.scopes || [], + email: existing.email || undefined, + emailAddress: existing.email || undefined + }, + email: existing.email || undefined + }; + + const credentialsJson = JSON.stringify(newCredentialData); + // Use base64 encoding for maximum security - prevents all injection attacks + const base64Json = encodeBase64ForPowerShell(credentialsJson); + + // PowerShell script to write to Credential Manager + const psScript = ` + $ErrorActionPreference = 'Stop' + + # Use CredWrite from advapi32.dll to write generic credentials + $sig = @' + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredWrite(ref CREDENTIAL credential, int flags); +'@ + Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential + + # Decode base64 JSON (more secure than string escaping) + $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64Json}')) + $jsonBytes = [System.Text.Encoding]::Unicode.GetBytes($json) + $jsonPtr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($jsonBytes.Length) + [System.Runtime.InteropServices.Marshal]::Copy($jsonBytes, 0, $jsonPtr, $jsonBytes.Length) + + try { + $cred = New-Object Win32.Credential+CREDENTIAL + $cred.Type = 1 # CRED_TYPE_GENERIC + $cred.TargetName = "${escapePowerShellString(targetName)}" + $cred.CredentialBlob = $jsonPtr + $cred.CredentialBlobSize = $jsonBytes.Length + $cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE + $cred.UserName = "claude-ai-oauth" + + $success = [Win32.Credential]::CredWrite([ref]$cred, 0) + if (-not $success) { + throw "CredWrite failed" + } + Write-Output "SUCCESS" + } finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($jsonPtr) + } + `; + + const result = execFileSync( + psPath, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript], + { + encoding: 'utf-8', + timeout: WINDOWS_CREDMAN_TIMEOUT_MS, + windowsHide: true, + } + ); + + if (result.trim() !== 'SUCCESS') { + return { success: false, error: 'Credential Manager update failed' }; + } + + if (isDebug) { + console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName); + } + + // Clear cached credentials to ensure fresh values are read + clearCredentialCache(configDir); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[CredentialUtils:Windows:Update] Failed to update Credential Manager:', errorMessage); + return { success: false, error: `Credential Manager update failed: ${errorMessage}` }; + } +} + +/** + * Update credentials in the platform-specific secure storage with new tokens. + * Called after a successful OAuth token refresh to persist the new tokens. + * + * CRITICAL: This must be called immediately after token refresh because the old tokens + * are revoked by Anthropic as soon as new tokens are issued. + * + * @param configDir - Config directory for the profile (undefined for default profile) + * @param credentials - New credentials to store + * @returns Result indicating success or failure + */ +export function updateKeychainCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + if (isMacOS()) { + return updateMacOSKeychainCredentials(configDir, credentials); + } + + if (isLinux()) { + return updateLinuxCredentials(configDir, credentials); + } + + if (isWindows()) { + return updateWindowsCredentialManagerCredentials(configDir, credentials); + } + + return { success: false, error: `Unsupported platform: ${process.platform}` }; +} diff --git a/apps/frontend/src/main/claude-profile/profile-scorer.ts b/apps/frontend/src/main/claude-profile/profile-scorer.ts index 25b58816..6498a04c 100644 --- a/apps/frontend/src/main/claude-profile/profile-scorer.ts +++ b/apps/frontend/src/main/claude-profile/profile-scorer.ts @@ -1,28 +1,147 @@ /** * Profile Scorer Module * Handles profile availability scoring and auto-switch logic + * + * Priority-Based Selection (v2): + * 1. User's configured priority order is the PRIMARY factor + * 2. Accounts are filtered by availability criteria: + * - Must be authenticated + * - Must not be rate-limited (explicit 429 error) + * - Must be below user's configured thresholds (default: 95% session, 99% weekly) + * 3. First profile in priority order that passes all filters is selected + * 4. If no profile passes all filters, falls back to "least bad" option */ import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types'; import { isProfileRateLimited } from './rate-limit-manager'; import { isProfileAuthenticated } from './profile-utils'; +const isDebug = process.env.DEBUG === 'true'; + interface ScoredProfile { profile: ClaudeProfile; score: number; + priorityIndex: number; + isAvailable: boolean; + unavailableReason?: string; } /** - * Get the best profile to switch to based on usage and rate limit status - * Returns null if no good alternative is available + * Check if a profile is available for use based on all criteria + */ +function checkProfileAvailability( + profile: ClaudeProfile, + settings: ClaudeAutoSwitchSettings +): { available: boolean; reason?: string } { + // Check authentication + if (!isProfileAuthenticated(profile)) { + return { available: false, reason: 'not authenticated' }; + } + + // Check explicit rate limit (from 429 errors) + const rateLimitStatus = isProfileRateLimited(profile); + if (rateLimitStatus.limited) { + return { + available: false, + reason: `rate limited (${rateLimitStatus.type}, resets ${rateLimitStatus.resetAt?.toISOString() || 'unknown'})` + }; + } + + // Check usage thresholds + if (profile.usage) { + // Weekly threshold check (more important - longer reset time) + // Using >= to reject profiles AT or ABOVE threshold (e.g., 95% is rejected when threshold is 95%) + // This is intentional: we want to switch proactively BEFORE hitting hard limits + if (profile.usage.weeklyUsagePercent >= settings.weeklyThreshold) { + return { + available: false, + reason: `weekly usage ${profile.usage.weeklyUsagePercent}% >= threshold ${settings.weeklyThreshold}%` + }; + } + + // Session threshold check + // Using >= to reject profiles AT or ABOVE threshold (same rationale as weekly) + if (profile.usage.sessionUsagePercent >= settings.sessionThreshold) { + return { + available: false, + reason: `session usage ${profile.usage.sessionUsagePercent}% >= threshold ${settings.sessionThreshold}%` + }; + } + } + + return { available: true }; +} + +/** + * Calculate a fallback score for when no profiles meet all criteria + * Used to pick the "least bad" option + */ +function calculateFallbackScore( + profile: ClaudeProfile, + settings: ClaudeAutoSwitchSettings +): number { + let score = 100; + const now = new Date(); + + // Authentication is critical + if (!isProfileAuthenticated(profile)) { + score -= 1000; // Unauthenticated is basically unusable + } + + // Rate limit status + const rateLimitStatus = isProfileRateLimited(profile); + if (rateLimitStatus.limited) { + if (rateLimitStatus.type === 'weekly') { + score -= 500; // Weekly limit is worse (longer reset) + } else { + score -= 200; // Session limit resets sooner + } + + // Bonus for profiles that reset sooner + if (rateLimitStatus.resetAt) { + const hoursUntilReset = (rateLimitStatus.resetAt.getTime() - now.getTime()) / (1000 * 60 * 60); + score += Math.max(0, 50 - hoursUntilReset); + } + } + + // Usage penalties (prefer lower usage) + if (profile.usage) { + // Penalize based on how far over threshold + const weeklyOverage = Math.max(0, profile.usage.weeklyUsagePercent - settings.weeklyThreshold); + const sessionOverage = Math.max(0, profile.usage.sessionUsagePercent - settings.sessionThreshold); + + score -= weeklyOverage * 2; // Weekly overage is worse + score -= sessionOverage; + + // Also factor in absolute usage (lower is better) + score -= profile.usage.weeklyUsagePercent * 0.3; + score -= profile.usage.sessionUsagePercent * 0.1; + } + + return score; +} + +/** + * Get the best profile to switch to based on priority order and availability + * + * Selection Logic: + * 1. Filter to candidates (excluding the current profile) + * 2. Check each profile's availability (auth, rate limit, thresholds) + * 3. Sort by user's priority order + * 4. Return the first available profile in priority order + * 5. If none available, return the "least bad" option based on fallback scoring + * + * @param profiles - All Claude profiles + * @param settings - Auto-switch settings (contains thresholds) + * @param excludeProfileId - Profile ID to exclude (usually the current/failing one) + * @param priorityOrder - User's configured priority order (array of unified IDs like 'oauth-{id}') */ export function getBestAvailableProfile( profiles: ClaudeProfile[], settings: ClaudeAutoSwitchSettings, - excludeProfileId?: string + excludeProfileId?: string, + priorityOrder: string[] = [] ): ClaudeProfile | null { - const now = new Date(); - // Get all profiles except the excluded one const candidates = profiles.filter(p => p.id !== excludeProfileId); @@ -30,79 +149,77 @@ export function getBestAvailableProfile( return null; } - // Score each profile based on: - // 1. Not rate-limited (highest priority) - // 2. Lower weekly usage (more important than session) - // 3. Lower session usage - // 4. More recently authenticated - const isDebug = process.env.DEBUG === 'true'; - if (isDebug) { console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate profiles (excluding:', excludeProfileId, ')'); + console.warn('[ProfileScorer] Priority order:', priorityOrder); + console.warn('[ProfileScorer] Thresholds: session =', settings.sessionThreshold, '%, weekly =', settings.weeklyThreshold, '%'); } + // Score and check availability for each profile const scoredProfiles: ScoredProfile[] = candidates.map(profile => { - let score = 100; // Base score - if (isDebug) console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')'); + const unifiedId = `oauth-${profile.id}`; + const priorityIndex = priorityOrder.indexOf(unifiedId); + const availability = checkProfileAvailability(profile, settings); + const fallbackScore = calculateFallbackScore(profile, settings); - // Check rate limit status - const rateLimitStatus = isProfileRateLimited(profile); - if (isDebug) console.warn('[ProfileScorer] Rate limit status:', rateLimitStatus); - if (rateLimitStatus.limited) { - // Severely penalize rate-limited profiles - if (rateLimitStatus.type === 'weekly') { - score -= 1000; // Weekly limit is worse - } else { - score -= 500; // Session limit will reset sooner - } - - // But add back some score based on how soon it resets - if (rateLimitStatus.resetAt) { - const hoursUntilReset = (rateLimitStatus.resetAt.getTime() - now.getTime()) / (1000 * 60 * 60); - score += Math.max(0, 50 - hoursUntilReset); // Closer reset = higher score - } + if (isDebug) { + console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')'); + console.warn('[ProfileScorer] Priority index:', priorityIndex === -1 ? 'not in list (Infinity)' : priorityIndex); + console.warn('[ProfileScorer] Available:', availability.available, availability.reason ? `(${availability.reason})` : ''); + console.warn('[ProfileScorer] Usage:', profile.usage ? `session=${profile.usage.sessionUsagePercent}%, weekly=${profile.usage.weeklyUsagePercent}%` : 'unknown'); + console.warn('[ProfileScorer] Fallback score:', fallbackScore); } - // Factor in current usage (if known) - if (profile.usage) { - // Weekly usage is more important - score -= profile.usage.weeklyUsagePercent * 0.5; - // Session usage is less important (resets more frequently) - score -= profile.usage.sessionUsagePercent * 0.2; - - // Penalize if above thresholds - if (profile.usage.weeklyUsagePercent >= settings.weeklyThreshold) { - score -= 200; - } - if (profile.usage.sessionUsagePercent >= settings.sessionThreshold) { - score -= 100; - } - } - - // Check if authenticated - const isAuth = isProfileAuthenticated(profile); - if (isDebug) console.warn('[ProfileScorer] isProfileAuthenticated:', isAuth, 'hasOAuthToken:', !!profile.oauthToken, 'hasConfigDir:', !!profile.configDir); - if (!isAuth) { - score -= 500; // Severely penalize unauthenticated profiles - if (isDebug) console.warn('[ProfileScorer] Applied -500 penalty for no auth'); - } - - if (isDebug) console.warn('[ProfileScorer] Final score:', score); - return { profile, score }; + return { + profile, + score: fallbackScore, + priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex, + isAvailable: availability.available, + unavailableReason: availability.reason + }; }); - // Sort by score (highest first) - scoredProfiles.sort((a, b) => b.score - a.score); + // Sort by: + // 1. Available profiles first + // 2. Within available: by priority index (lower = higher priority) + // 3. Within unavailable: by fallback score (higher = better) + scoredProfiles.sort((a, b) => { + // Available profiles always come first + if (a.isAvailable !== b.isAvailable) { + return a.isAvailable ? -1 : 1; + } + + // For available profiles, sort by priority order + if (a.isAvailable && b.isAvailable) { + // If both have priority indices, use them + if (a.priorityIndex !== b.priorityIndex) { + return a.priorityIndex - b.priorityIndex; + } + // Tiebreaker: prefer lower usage + return b.score - a.score; + } + + // For unavailable profiles, sort by fallback score (for "least bad" selection) + return b.score - a.score; + }); - // Return the best candidate if it has a positive score const best = scoredProfiles[0]; - if (best && best.score > 0) { - console.warn('[ProfileScorer] Best available profile:', best.profile.name, 'score:', best.score); + + if (best.isAvailable) { + console.warn('[ProfileScorer] Best available profile:', best.profile.name, '(priority index:', best.priorityIndex, ')'); return best.profile; } - // All profiles are rate-limited or have issues - console.warn('[ProfileScorer] No good profile available, all are rate-limited or have issues'); + // No profile meets all criteria - check if we should return the least bad option + // Only return if it has a positive score (meaning it might still work) + if (best.score > 0) { + console.warn('[ProfileScorer] No ideal profile available, using least-bad option:', best.profile.name, + '(score:', best.score, ', reason:', best.unavailableReason, ')'); + return best.profile; + } + + // All profiles are truly unusable + console.warn('[ProfileScorer] No usable profile available, all have issues'); return null; } @@ -112,7 +229,8 @@ export function getBestAvailableProfile( export function shouldProactivelySwitch( profile: ClaudeProfile, allProfiles: ClaudeProfile[], - settings: ClaudeAutoSwitchSettings + settings: ClaudeAutoSwitchSettings, + priorityOrder: string[] = [] ): { shouldSwitch: boolean; reason?: string; suggestedProfile?: ClaudeProfile } { if (!settings.enabled) { return { shouldSwitch: false }; @@ -126,7 +244,7 @@ export function shouldProactivelySwitch( // Check if we're approaching limits if (usage.weeklyUsagePercent >= settings.weeklyThreshold) { - const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id); + const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder); if (bestProfile) { return { shouldSwitch: true, @@ -137,7 +255,7 @@ export function shouldProactivelySwitch( } if (usage.sessionUsagePercent >= settings.sessionThreshold) { - const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id); + const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder); if (bestProfile) { return { shouldSwitch: true, @@ -152,11 +270,17 @@ export function shouldProactivelySwitch( /** * Get profiles sorted by availability (best first) + * This is a simpler sort that doesn't consider priority order - used for display purposes */ export function getProfilesSortedByAvailability(profiles: ClaudeProfile[]): ClaudeProfile[] { - const _now = new Date(); - return [...profiles].sort((a, b) => { + // Authenticated profiles first + const aAuth = isProfileAuthenticated(a); + const bAuth = isProfileAuthenticated(b); + if (aAuth !== bAuth) { + return aAuth ? -1 : 1; + } + // Not rate-limited profiles first const aLimited = isProfileRateLimited(a); const bLimited = isProfileRateLimited(b); diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index 8d51e4c1..0c7800e4 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -3,10 +3,18 @@ * Handles persistence of profile data to disk */ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types'; +/** + * Directory constants for profile isolation + */ +const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), '.claude'); +const CLAUDE_PROFILES_DIR = join(homedir(), '.claude-profiles'); + export const STORE_VERSION = 3; // Bumped for encrypted token storage /** @@ -31,6 +39,94 @@ 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; } /** @@ -47,6 +143,9 @@ function parseAndMigrateProfileData(data: Record): 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) => { @@ -61,8 +160,23 @@ function parseAndMigrateProfileData(data: Record): 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 ? { @@ -76,6 +190,14 @@ function parseAndMigrateProfileData(data: Record): 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; } diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index 489c45de..793a61f9 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -7,6 +7,7 @@ import { homedir } from 'os'; import { join } from 'path'; import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs'; import type { ClaudeProfile } from '../../shared/types'; +import { getCredentialsFromKeychain } from './credential-utils'; /** * Default Claude config directory @@ -38,18 +39,17 @@ export function generateProfileId(name: string, existingProfiles: ClaudeProfile[ * Create a new profile directory and initialize it */ export async function createProfileDirectory(profileName: string): Promise { - // Ensure profiles directory exists - if (!existsSync(CLAUDE_PROFILES_DIR)) { - mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true }); - } + // Create profiles directory - mkdirSync with recursive:true is idempotent + // and won't throw if the directory already exists, so no existsSync check needed + mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true }); // Create directory for this profile const sanitizedName = profileName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const profileDir = join(CLAUDE_PROFILES_DIR, sanitizedName); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } + // mkdirSync with recursive:true is idempotent and won't throw if directory exists + // No existsSync check needed - avoids TOCTOU race condition + mkdirSync(profileDir, { recursive: true }); return profileDir; } @@ -80,6 +80,21 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean { const data = JSON.parse(content); // Check for oauthAccount which indicates successful OAuth authentication if (data && typeof data === 'object' && (data.oauthAccount?.accountUuid || data.oauthAccount?.emailAddress)) { + // The actual OAuth tokens are stored in platform-specific credential storage: + // - macOS: Keychain + // - Windows: Credential Manager + // - Linux: Secret Service or .credentials.json file + // We need to verify that the credential store actually has the tokens + // Expand ~ in configDir before checking credentials + const expandedConfigDir = configDir.startsWith('~') + ? configDir.replace(/^~/, homedir()) + : configDir; + const platformCreds = getCredentialsFromKeychain(expandedConfigDir); + if (!platformCreds.token) { + // .claude.json exists but credential store is missing tokens - NOT authenticated + console.warn(`[profile-utils] Profile has .claude.json but no platform credentials for: ${configDir}`); + return false; + } return true; } } catch (error) { diff --git a/apps/frontend/src/main/claude-profile/token-refresh.test.ts b/apps/frontend/src/main/claude-profile/token-refresh.test.ts new file mode 100644 index 00000000..b0ff2378 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/token-refresh.test.ts @@ -0,0 +1,348 @@ +/** + * Tests for OAuth Token Refresh Module + * + * Tests token expiry detection and refresh functionality. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isTokenExpiredOrNearExpiry, + getTimeUntilExpiry, + formatTimeRemaining, + refreshOAuthToken, + ensureValidToken, + reactiveTokenRefresh, + type TokenRefreshResult +} from './token-refresh'; + +// Mock credential-utils +vi.mock('./credential-utils', () => ({ + getFullCredentialsFromKeychain: vi.fn(() => ({ + token: 'mock-access-token', + email: 'test@example.com', + refreshToken: 'mock-refresh-token', + expiresAt: Date.now() + 3600000, // 1 hour from now + scopes: ['user:read'] + })), + updateKeychainCredentials: vi.fn(() => ({ success: true })), + clearKeychainCache: vi.fn() +})); + +// Mock fetch for token refresh +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('token-refresh', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-20T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('isTokenExpiredOrNearExpiry', () => { + it('should return true when expiresAt is null', () => { + expect(isTokenExpiredOrNearExpiry(null)).toBe(true); + }); + + it('should return true when token is expired', () => { + const expiredAt = Date.now() - 1000; // 1 second ago + expect(isTokenExpiredOrNearExpiry(expiredAt)).toBe(true); + }); + + it('should return true when token is within threshold', () => { + const expiresIn25Min = Date.now() + 25 * 60 * 1000; // 25 minutes + // Default threshold is 30 minutes + expect(isTokenExpiredOrNearExpiry(expiresIn25Min)).toBe(true); + }); + + it('should return false when token is valid beyond threshold', () => { + const expiresIn2Hours = Date.now() + 2 * 60 * 60 * 1000; + expect(isTokenExpiredOrNearExpiry(expiresIn2Hours)).toBe(false); + }); + + it('should respect custom threshold', () => { + const expiresIn45Min = Date.now() + 45 * 60 * 1000; + const threshold1Hour = 60 * 60 * 1000; + + // Within 1 hour threshold = near expiry + expect(isTokenExpiredOrNearExpiry(expiresIn45Min, threshold1Hour)).toBe(true); + + // Beyond 30 minute threshold = valid + expect(isTokenExpiredOrNearExpiry(expiresIn45Min, 30 * 60 * 1000)).toBe(false); + }); + }); + + describe('getTimeUntilExpiry', () => { + it('should return null when expiresAt is null', () => { + expect(getTimeUntilExpiry(null)).toBeNull(); + }); + + it('should return 0 for expired tokens', () => { + const expired = Date.now() - 1000; + expect(getTimeUntilExpiry(expired)).toBe(0); + }); + + it('should return correct time remaining', () => { + const expiresIn1Hour = Date.now() + 60 * 60 * 1000; + const remaining = getTimeUntilExpiry(expiresIn1Hour); + + expect(remaining).toBeCloseTo(60 * 60 * 1000, -2); // Within 100ms + }); + }); + + describe('formatTimeRemaining', () => { + it('should return "unknown" for null', () => { + expect(formatTimeRemaining(null)).toBe('unknown'); + }); + + it('should return "expired" for 0 or negative', () => { + expect(formatTimeRemaining(0)).toBe('expired'); + expect(formatTimeRemaining(-1000)).toBe('expired'); + }); + + it('should format minutes correctly', () => { + expect(formatTimeRemaining(45 * 60 * 1000)).toBe('45m'); + expect(formatTimeRemaining(5 * 60 * 1000)).toBe('5m'); + }); + + it('should format hours and minutes correctly', () => { + expect(formatTimeRemaining(90 * 60 * 1000)).toBe('1h 30m'); + expect(formatTimeRemaining(3 * 60 * 60 * 1000 + 15 * 60 * 1000)).toBe('3h 15m'); + }); + }); + + describe('refreshOAuthToken', () => { + it('should return error when no refresh token provided', async () => { + const result = await refreshOAuthToken(''); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('missing_refresh_token'); + }); + + it('should successfully refresh token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 28800 + }) + }); + + const result = await refreshOAuthToken('old-refresh-token'); + + expect(result.success).toBe(true); + expect(result.accessToken).toBe('new-access-token'); + expect(result.refreshToken).toBe('new-refresh-token'); + expect(result.expiresIn).toBe(28800); + expect(result.expiresAt).toBeDefined(); + }); + + it('should handle invalid_grant error without retry', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + error: 'invalid_grant', + error_description: 'Refresh token is invalid or expired' + }) + }); + + const result = await refreshOAuthToken('invalid-refresh-token'); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('invalid_grant'); + expect(mockFetch).toHaveBeenCalledTimes(1); // No retries + }); + + it('should retry on network errors', async () => { + mockFetch + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 28800 + }) + }); + + // Start the async operation + const resultPromise = refreshOAuthToken('valid-refresh-token'); + + // Advance timers to handle retry delays (1s, 2s exponential backoff) + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('should fail after max retries', async () => { + mockFetch.mockRejectedValue(new Error('Persistent network error')); + + // Start the async operation + const resultPromise = refreshOAuthToken('valid-refresh-token'); + + // Advance timers to handle retry delays + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('network_error'); + expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries + }); + }); + + describe('ensureValidToken', () => { + it('should return existing token if not near expiry', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 2 * 60 * 60 * 1000, // 2 hours + email: 'test@example.com' + }); + + const result = await ensureValidToken(undefined); + + expect(result.token).toBe('valid-token'); + expect(result.wasRefreshed).toBe(false); + }); + + it('should refresh token when near expiry', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'old-token', + refreshToken: 'valid-refresh-token', + expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes - within threshold + email: 'test@example.com' + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 28800 + }) + }); + + const result = await ensureValidToken(undefined); + + expect(result.wasRefreshed).toBe(true); + expect(result.token).toBe('new-token'); + }); + + it('should return error when no token available', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: null, + refreshToken: null, + expiresAt: null, + email: null + }); + + const result = await ensureValidToken(undefined); + + expect(result.token).toBeNull(); + expect(result.error).toContain('No access token'); + }); + + it('should return existing token if no refresh token available', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'expiring-token', + refreshToken: null, // No refresh token + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes + email: 'test@example.com' + }); + + const result = await ensureValidToken(undefined); + + expect(result.token).toBe('expiring-token'); + expect(result.wasRefreshed).toBe(false); + expect(result.error).toContain('no refresh token'); + }); + + it('should call onRefreshed callback when token is refreshed', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'old-token', + refreshToken: 'valid-refresh', + expiresAt: Date.now() + 5 * 60 * 1000, + email: 'test@example.com' + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 28800 + }) + }); + + const onRefreshed = vi.fn(); + await ensureValidToken(undefined, onRefreshed); + + expect(onRefreshed).toHaveBeenCalledWith( + undefined, + 'new-token', + 'new-refresh', + expect.any(Number) + ); + }); + }); + + describe('reactiveTokenRefresh', () => { + it('should force refresh even if token appears valid', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'current-token', + refreshToken: 'valid-refresh', + expiresAt: Date.now() + 2 * 60 * 60 * 1000, // 2 hours + email: 'test@example.com' + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 28800 + }) + }); + + const result = await reactiveTokenRefresh(undefined); + + expect(result.wasRefreshed).toBe(true); + expect(result.token).toBe('new-token'); + }); + + it('should return error when no refresh token available', async () => { + const { getFullCredentialsFromKeychain } = await import('./credential-utils'); + (getFullCredentialsFromKeychain as ReturnType).mockReturnValue({ + token: 'current-token', + refreshToken: null, + expiresAt: Date.now() + 2 * 60 * 60 * 1000, + email: 'test@example.com' + }); + + const result = await reactiveTokenRefresh(undefined); + + expect(result.token).toBeNull(); + expect(result.error).toContain('No refresh token'); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-profile/token-refresh.ts b/apps/frontend/src/main/claude-profile/token-refresh.ts new file mode 100644 index 00000000..ee31418c --- /dev/null +++ b/apps/frontend/src/main/claude-profile/token-refresh.ts @@ -0,0 +1,538 @@ +/** + * OAuth Token Refresh Module + * + * Handles automatic token refresh for Claude Code OAuth tokens. + * Supports proactive refresh (before expiry) and reactive refresh (on 401 errors). + * + * CRITICAL: When a token is refreshed, the old token is IMMEDIATELY REVOKED by Anthropic. + * Therefore, new tokens must be written back to the credential store immediately. + * + * Verified endpoint: + * POST https://console.anthropic.com/v1/oauth/token + * Content-Type: application/x-www-form-urlencoded + * Body: grant_type=refresh_token&refresh_token=sk-ant-ort01-...&client_id= + * Response: { access_token, refresh_token, expires_in: 28800, token_type: "Bearer" } + */ + +import { homedir } from 'os'; +import { + getFullCredentialsFromKeychain, + updateKeychainCredentials, + clearKeychainCache, +} from './credential-utils'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Anthropic OAuth token endpoint + */ +const ANTHROPIC_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token'; + +/** + * Claude Code OAuth client ID (public - same for all Claude Code installations) + * This is the official client ID used by Claude Code CLI + */ +const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; + +/** + * Proactive refresh threshold: refresh tokens 30 minutes before expiry + * This provides a buffer to handle network issues and ensures tokens are + * always valid when needed for autonomous overnight operation. + */ +const PROACTIVE_REFRESH_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Maximum retry attempts for token refresh + */ +const MAX_REFRESH_RETRIES = 2; + +/** + * Delay between retry attempts (exponential backoff base) + */ +const RETRY_DELAY_BASE_MS = 1000; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Result of a token refresh operation + */ +export interface TokenRefreshResult { + success: boolean; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; // Unix timestamp in ms + expiresIn?: number; // Seconds until expiry + error?: string; + errorCode?: string; // 'invalid_grant', 'invalid_client', 'network_error', etc. +} + +/** + * Result of ensuring a valid token + */ +export interface EnsureValidTokenResult { + token: string | null; + wasRefreshed: boolean; + error?: string; + errorCode?: string; // 'invalid_grant', 'invalid_client', 'network_error', etc. + /** + * True if token was refreshed but failed to persist to keychain. + * The token is valid for this session but will be lost on restart. + * Callers should alert the user to re-authenticate. + */ + persistenceFailed?: boolean; +} + +/** + * Callback for when tokens are refreshed + */ +export type OnTokenRefreshedCallback = ( + configDir: string | undefined, + newAccessToken: string, + newRefreshToken: string, + expiresAt: number +) => void; + +// ============================================================================= +// Token Expiry Detection +// ============================================================================= + +/** + * Check if a token is expired or near expiry. + * + * @param expiresAt - Unix timestamp in ms when the token expires, or null if unknown + * @param thresholdMs - How far before expiry to consider "near expiry" (default: 30 minutes) + * @returns true if token is expired or will expire within the threshold + */ +export function isTokenExpiredOrNearExpiry( + expiresAt: number | null, + thresholdMs: number = PROACTIVE_REFRESH_THRESHOLD_MS +): boolean { + // If we don't know the expiry time, assume it might be expired + // This is safer than assuming it's valid + if (expiresAt === null) { + return true; + } + + const now = Date.now(); + const expiryThreshold = expiresAt - thresholdMs; + + return now >= expiryThreshold; +} + +/** + * Get time remaining until token expiry. + * + * @param expiresAt - Unix timestamp in ms when the token expires + * @returns Time remaining in ms, or null if expiresAt is null + */ +export function getTimeUntilExpiry(expiresAt: number | null): number | null { + if (expiresAt === null) return null; + return Math.max(0, expiresAt - Date.now()); +} + +/** + * Format time remaining for logging + */ +export function formatTimeRemaining(ms: number | null): string { + if (ms === null) return 'unknown'; + if (ms <= 0) return 'expired'; + + const minutes = Math.floor(ms / (60 * 1000)); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + } + return `${minutes}m`; +} + +// ============================================================================= +// Token Refresh +// ============================================================================= + +/** + * Refresh an OAuth token using the refresh_token grant type. + * + * CRITICAL: After a successful refresh, the old access token AND refresh token are REVOKED. + * The new tokens must be stored immediately. + * + * @param refreshToken - The refresh token to use + * @returns Result containing new tokens or error information + */ +export async function refreshOAuthToken(refreshToken: string): Promise { + const isDebug = process.env.DEBUG === 'true'; + + if (isDebug) { + // Reduce fingerprint to fewer characters to minimize information exposure + // Show only first 4 and last 2 characters for debugging purposes + console.warn('[TokenRefresh] Starting token refresh', { + refreshTokenFingerprint: refreshToken ? `${refreshToken.slice(0, 4)}...${refreshToken.slice(-2)}` : 'null' + }); + } + + if (!refreshToken) { + return { + success: false, + error: 'No refresh token provided', + errorCode: 'missing_refresh_token' + }; + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) { + if (attempt > 0) { + // Exponential backoff between retries + const delay = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); + if (isDebug) { + console.warn('[TokenRefresh] Retrying after delay:', delay, 'ms (attempt', attempt + 1, ')'); + } + await new Promise(resolve => setTimeout(resolve, delay)); + } + + try { + // Build form-urlencoded body + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CLAUDE_CODE_CLIENT_ID + }); + + const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + if (!response.ok) { + let errorData: Record = {}; + try { + errorData = await response.json(); + } catch { + // Ignore JSON parse errors + } + + const errorCode = errorData.error || `http_${response.status}`; + const errorDescription = errorData.error_description || response.statusText; + + // Check for permanent errors that shouldn't be retried + if (errorCode === 'invalid_grant' || errorCode === 'invalid_client') { + console.error('[TokenRefresh] Permanent error - refresh token invalid:', { + errorCode, + errorDescription + }); + return { + success: false, + error: `Token refresh failed: ${errorDescription}`, + errorCode + }; + } + + // Temporary errors - continue to retry + lastError = new Error(`HTTP ${response.status}: ${errorDescription}`); + if (isDebug) { + console.warn('[TokenRefresh] Temporary error, will retry:', lastError.message); + } + continue; + } + + // Parse successful response + const data = await response.json(); + + if (!data.access_token) { + return { + success: false, + error: 'Response missing access_token', + errorCode: 'invalid_response' + }; + } + + // Calculate expiry timestamp + // expires_in is in seconds, convert to ms and add to current time + const expiresIn = data.expires_in || 28800; // Default 8 hours if not provided + const expiresAt = Date.now() + (expiresIn * 1000); + + if (isDebug) { + console.warn('[TokenRefresh] Token refresh successful', { + newTokenFingerprint: `${data.access_token.slice(0, 12)}...${data.access_token.slice(-4)}`, + expiresIn: expiresIn, + expiresAt: new Date(expiresAt).toISOString() + }); + } + + return { + success: true, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt, + expiresIn + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (isDebug) { + console.warn('[TokenRefresh] Network error, will retry:', lastError.message); + } + } + } + + // All retries exhausted + console.error('[TokenRefresh] All retry attempts failed'); + return { + success: false, + error: lastError?.message || 'Token refresh failed after retries', + errorCode: 'network_error' + }; +} + +// ============================================================================= +// Integrated Token Validation and Refresh +// ============================================================================= + +/** + * Ensure a valid token is available, refreshing if necessary. + * + * This function: + * 1. Reads credentials from keychain + * 2. Checks if token is expired or near expiry + * 3. If needed, refreshes the token and writes back to keychain + * 4. Returns a valid token + * + * @param configDir - Config directory for the profile (can be undefined for default profile) + * @param onRefreshed - Optional callback when tokens are refreshed + * @returns Valid token or null with error information + */ +export async function ensureValidToken( + configDir: string | undefined, + onRefreshed?: OnTokenRefreshedCallback +): Promise { + const isDebug = process.env.DEBUG === 'true'; + + // Expand ~ in configDir if present + const expandedConfigDir = configDir?.startsWith('~') + ? configDir.replace(/^~/, homedir()) + : configDir; + + if (isDebug) { + console.warn('[TokenRefresh:ensureValidToken] Checking token validity', { + configDir: expandedConfigDir || 'default' + }); + } + + // Step 1: Read full credentials from keychain + const creds = getFullCredentialsFromKeychain(expandedConfigDir); + + if (creds.error) { + return { + token: null, + wasRefreshed: false, + error: `Failed to read credentials: ${creds.error}` + }; + } + + if (!creds.token) { + return { + token: null, + wasRefreshed: false, + error: 'No access token found in credentials', + errorCode: 'missing_credentials' + }; + } + + // Step 2: Check if token is expired or near expiry + const needsRefresh = isTokenExpiredOrNearExpiry(creds.expiresAt); + + if (!needsRefresh) { + if (isDebug) { + console.warn('[TokenRefresh:ensureValidToken] Token is valid', { + timeRemaining: formatTimeRemaining(getTimeUntilExpiry(creds.expiresAt)) + }); + } + return { + token: creds.token, + wasRefreshed: false + }; + } + + if (isDebug) { + console.warn('[TokenRefresh:ensureValidToken] Token needs refresh', { + expiresAt: creds.expiresAt ? new Date(creds.expiresAt).toISOString() : 'unknown', + hasRefreshToken: !!creds.refreshToken + }); + } + + // Step 3: Check if we have a refresh token + if (!creds.refreshToken) { + // Can't refresh - return existing token and let caller handle potential 401 + if (isDebug) { + console.warn('[TokenRefresh:ensureValidToken] No refresh token available, returning existing token'); + } + return { + token: creds.token, + wasRefreshed: false, + error: 'Token expired but no refresh token available' + }; + } + + // Step 4: Refresh the token + const refreshResult = await refreshOAuthToken(creds.refreshToken); + + if (!refreshResult.success || !refreshResult.accessToken || !refreshResult.refreshToken || !refreshResult.expiresAt) { + console.error('[TokenRefresh:ensureValidToken] Token refresh failed:', refreshResult.error); + // CRITICAL: When token refresh fails server-side, the old token may already be revoked. + // Returning the old token here is a best-effort fallback, but callers should be aware + // that it will likely result in 401 errors. The comment "it might still work" is optimistic. + // This scenario indicates the user needs to re-authenticate via OAuth flow. + return { + token: creds.token, // WARNING: This token may be invalid/revoked + wasRefreshed: false, + error: `Token refresh failed: ${refreshResult.error}`, + errorCode: refreshResult.errorCode + }; + } + + // Step 5: CRITICAL - Write new tokens to keychain immediately + // The old token is now REVOKED, so we must persist the new one + const updateResult = updateKeychainCredentials(expandedConfigDir, { + accessToken: refreshResult.accessToken, + refreshToken: refreshResult.refreshToken, + expiresAt: refreshResult.expiresAt, + scopes: creds.scopes || undefined + }); + + // Track if persistence failed - callers can alert user to re-authenticate + let persistenceFailed = false; + + if (!updateResult.success) { + // This is a critical error - we have new tokens but can't persist them + console.error('[TokenRefresh:ensureValidToken] CRITICAL: Failed to persist refreshed tokens:', updateResult.error); + console.error('[TokenRefresh:ensureValidToken] The new token will be lost on next restart!'); + persistenceFailed = true; + // Still return the new token for this session + } else { + if (isDebug) { + console.warn('[TokenRefresh:ensureValidToken] Successfully refreshed and persisted token', { + newExpiresAt: new Date(refreshResult.expiresAt).toISOString() + }); + } + } + + // Step 6: Clear the credential cache so next read gets fresh data + clearKeychainCache(expandedConfigDir); + + // Step 7: Call the callback if provided + if (onRefreshed) { + onRefreshed( + expandedConfigDir, + refreshResult.accessToken, + refreshResult.refreshToken, + refreshResult.expiresAt + ); + } + + return { + token: refreshResult.accessToken, + wasRefreshed: true, + ...(persistenceFailed && { persistenceFailed: true }) + }; +} + +/** + * Perform a reactive token refresh (called on 401 error). + * + * This is similar to ensureValidToken but: + * - Doesn't check expiry (we know the token is invalid) + * - Forces a refresh regardless of apparent token state + * + * @param configDir - Config directory for the profile + * @param onRefreshed - Optional callback when tokens are refreshed + * @returns New token or null with error information + */ +export async function reactiveTokenRefresh( + configDir: string | undefined, + onRefreshed?: OnTokenRefreshedCallback +): Promise { + const isDebug = process.env.DEBUG === 'true'; + + const expandedConfigDir = configDir?.startsWith('~') + ? configDir.replace(/^~/, homedir()) + : configDir; + + if (isDebug) { + console.warn('[TokenRefresh:reactive] Performing reactive token refresh (401 received)', { + configDir: expandedConfigDir || 'default' + }); + } + + // Read credentials to get refresh token + const creds = getFullCredentialsFromKeychain(expandedConfigDir); + + if (creds.error) { + return { + token: null, + wasRefreshed: false, + error: `Failed to read credentials: ${creds.error}` + }; + } + + if (!creds.refreshToken) { + return { + token: null, + wasRefreshed: false, + error: 'No refresh token available for reactive refresh' + }; + } + + // Perform refresh + const refreshResult = await refreshOAuthToken(creds.refreshToken); + + if (!refreshResult.success || !refreshResult.accessToken || !refreshResult.refreshToken || !refreshResult.expiresAt) { + return { + token: null, + wasRefreshed: false, + error: `Reactive refresh failed: ${refreshResult.error}` + }; + } + + // Write new tokens to keychain + const updateResult = updateKeychainCredentials(expandedConfigDir, { + accessToken: refreshResult.accessToken, + refreshToken: refreshResult.refreshToken, + expiresAt: refreshResult.expiresAt, + scopes: creds.scopes || undefined + }); + + // Track if persistence failed - callers can alert user to re-authenticate + let persistenceFailed = false; + if (!updateResult.success) { + console.error('[TokenRefresh:reactive] CRITICAL: Failed to persist refreshed tokens:', updateResult.error); + persistenceFailed = true; + } + + clearKeychainCache(expandedConfigDir); + + if (onRefreshed) { + onRefreshed( + expandedConfigDir, + refreshResult.accessToken, + refreshResult.refreshToken, + refreshResult.expiresAt + ); + } + + if (isDebug) { + console.warn('[TokenRefresh:reactive] Reactive refresh successful'); + } + + return { + token: refreshResult.accessToken, + wasRefreshed: true, + ...(persistenceFailed && { persistenceFailed: true }) + }; +} diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts index 0928ce68..450476e5 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts @@ -84,6 +84,25 @@ describe('usage-monitor', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + + // Restore default fetch mock after clearAllMocks + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null) + }, + json: async () => ({ + five_hour_utilization: 0.5, + seven_day_utilization: 0.3, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }) + } as unknown as Response) + ); }); afterEach(() => { @@ -145,42 +164,38 @@ describe('usage-monitor', () => { it('should start monitoring when settings allow', () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - monitor.start(); - // Check that console.warn was called (monitor logs when starting) - expect(consoleSpy).toHaveBeenCalled(); + // Verify monitor started (has intervalId set) + expect(monitor['intervalId']).not.toBeNull(); - consoleSpy.mockRestore(); monitor.stop(); }); it('should not start if already running', () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); monitor.start(); + const firstIntervalId = monitor['intervalId']; + monitor.start(); // Second call should be ignored - // Should have logged a warning that it's already running - expect(consoleSpy.mock.calls.length).toBeGreaterThan(0); + // Should still have the same intervalId (not recreated) + expect(monitor['intervalId']).toBe(firstIntervalId); - consoleSpy.mockRestore(); monitor.stop(); }); it('should stop monitoring', () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); monitor.start(); + expect(monitor['intervalId']).not.toBeNull(); + monitor.stop(); - // Verify stop completed without errors - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); + // Verify intervalId is cleared + expect(monitor['intervalId']).toBeNull(); }); it('should return current usage snapshot', () => { @@ -852,16 +867,12 @@ describe('usage-monitor', () => { describe('Credential error handling', () => { it('should handle missing credential gracefully', async () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Call fetchUsage without credential const usage = await monitor['fetchUsage']('test-profile-1', undefined); // Should fall back to CLI method (which returns null) expect(usage).toBeNull(); - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); }); it('should handle empty credential string', async () => { @@ -900,15 +911,10 @@ describe('usage-monitor', () => { } as any); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Call checkUsageAndSwap directly to test null profile handling - await monitor['checkUsageAndSwap'](); - - // Should log a warning about no active profile - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No active profile')); - - consoleSpy.mockRestore(); + // Should complete without throwing an error + await expect(monitor['checkUsageAndSwap']()).resolves.toBeUndefined(); }); it('should handle profile with missing required fields', async () => { @@ -996,7 +1002,6 @@ describe('usage-monitor', () => { it('should handle unknown provider gracefully', async () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Create an active profile object with unknown provider const unknownProviderProfile = { @@ -1028,24 +1033,12 @@ describe('usage-monitor', () => { // Unknown provider should return null expect(usage).toBeNull(); - // Verify console.warn was called with "Unknown provider - no usage endpoint configured:" message - expect(consoleSpy).toHaveBeenCalledWith( - '[UsageMonitor] Unknown provider - no usage endpoint configured:', - expect.objectContaining({ - provider: 'unknown', - baseUrl: 'https://unknown-provider.com/api', - profileId: 'unknown-profile-1' - }) - ); - - consoleSpy.mockRestore(); }); }); describe('Concurrent check prevention', () => { it('should prevent concurrent usage checks', async () => { const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Start first check (it will take some time) const firstCheck = monitor['checkUsageAndSwap'](); @@ -1057,10 +1050,8 @@ describe('usage-monitor', () => { await firstCheck; await secondCheck; - // Verify checks completed - expect(consoleSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); + // Verify check completed (isChecking should be false after both complete) + expect(monitor['isChecking']).toBe(false); }); }); @@ -1161,15 +1152,13 @@ describe('usage-monitor', () => { } as any); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Should start with default values for missing fields monitor.start(); - // Default usageCheckInterval is 30000ms - expect(consoleSpy).toHaveBeenCalledWith('[UsageMonitor] Starting with interval:', 30000, 'ms (30-second updates for accurate usage stats)'); + // Should have started monitoring + expect(monitor['intervalId']).not.toBeNull(); - consoleSpy.mockRestore(); monitor.stop(); }); @@ -1204,14 +1193,13 @@ describe('usage-monitor', () => { } as any); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Should not crash when checking thresholds monitor.start(); - expect(consoleSpy).toHaveBeenCalled(); + // Should have started successfully + expect(monitor['intervalId']).not.toBeNull(); - consoleSpy.mockRestore(); monitor.stop(); }); }); @@ -1538,11 +1526,16 @@ describe('usage-monitor', () => { describe('Race condition prevention via activeProfile parameter', () => { it('should use passed activeProfile instead of re-detecting', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetch = vi.mocked(global.fetch); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK', + headers: { + get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null) + }, json: async () => ({ five_hour_utilization: 0.5, seven_day_utilization: 0.3, @@ -1564,7 +1557,6 @@ describe('usage-monitor', () => { }); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Pre-determined active profile (simulating profile at time of checkUsageAndSwap) const predeterminedProfile = { @@ -1583,12 +1575,19 @@ describe('usage-monitor', () => { predeterminedProfile ); + // Log any console.error calls for debugging + if (errorSpy.mock.calls.length > 0) { + console.log('console.error was called:', errorSpy.mock.calls); + } + // Should successfully fetch usage using the passed profile expect(usage).not.toBeNull(); - expect(usage?.profileId).toBe('api-profile-1'); - expect(usage?.sessionPercent).toBe(50); + if (usage) { + expect(usage.profileId).toBe('api-profile-1'); + expect(usage.sessionPercent).toBe(50); + } - consoleSpy.mockRestore(); + errorSpy.mockRestore(); }); it('should fall back to profile detection when activeProfile not provided', async () => { @@ -1597,6 +1596,9 @@ describe('usage-monitor', () => { ok: true, status: 200, statusText: 'OK', + headers: { + get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null) + }, json: async () => ({ five_hour_utilization: 0.5, seven_day_utilization: 0.3, @@ -1618,7 +1620,6 @@ describe('usage-monitor', () => { }); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Call fetchUsageViaAPI WITHOUT predetermined profile // Should fall back to detecting profile from activeProfileId @@ -1633,8 +1634,6 @@ describe('usage-monitor', () => { // Should still work by detecting the profile expect(usage).not.toBeNull(); expect(usage?.profileId).toBe('api-profile-1'); - - consoleSpy.mockRestore(); }); it('should handle OAuth profile in activeProfile parameter', async () => { @@ -1643,6 +1642,9 @@ describe('usage-monitor', () => { ok: true, status: 200, statusText: 'OK', + headers: { + get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null) + }, json: async () => ({ five_hour_utilization: 0.5, seven_day_utilization: 0.3, @@ -1652,7 +1654,6 @@ describe('usage-monitor', () => { } as unknown as Response); const monitor = getUsageMonitor(); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Pre-determined OAuth profile const oauthProfile = { @@ -1674,8 +1675,6 @@ describe('usage-monitor', () => { // Should successfully fetch usage for OAuth profile expect(usage).not.toBeNull(); expect(usage?.profileId).toBe('oauth-profile'); - - consoleSpy.mockRestore(); }); }); diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 439e556a..095a6d48 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -17,6 +17,7 @@ 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 { reactiveTokenRefresh, ensureValidToken } from './token-refresh'; import { isProfileRateLimited } from './rate-limit-manager'; // Re-export for backward compatibility @@ -201,6 +202,7 @@ export class UsageMonitor extends EventEmitter { private static instance: UsageMonitor; private intervalId: NodeJS.Timeout | null = null; private currentUsage: ClaudeUsageSnapshot | null = null; + private currentUsageProfileId: string | null = null; // Track which profile's usage is in currentUsage private isChecking = false; // Per-profile API failure tracking with cooldown-based retry @@ -212,6 +214,10 @@ export class UsageMonitor extends EventEmitter { private authFailedProfiles: Map = new Map(); // profileId -> timestamp private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown + // Track profiles that need re-authentication (invalid refresh token) + // These profiles have permanent auth failures that require manual re-auth + private needsReauthProfiles: Set = new Set(); + // Cache for all profiles' usage data // Map private allProfilesUsageCache: Map = new Map(); @@ -220,9 +226,22 @@ export class UsageMonitor extends EventEmitter { // Debug flag for verbose logging private readonly isDebug = process.env.DEBUG === 'true'; + /** + * Debug log helper - only logs when DEBUG=true + */ + private debugLog(message: string, data?: unknown): void { + if (this.isDebug) { + if (data !== undefined) { + console.warn(message, data); + } else { + console.warn(message); + } + } + } + private constructor() { super(); - console.warn('[UsageMonitor] Initialized'); + this.debugLog('[UsageMonitor] Initialized'); } static getInstance(): UsageMonitor { @@ -242,7 +261,7 @@ export class UsageMonitor extends EventEmitter { */ start(): void { if (this.intervalId) { - console.warn('[UsageMonitor] Already running'); + this.debugLog('[UsageMonitor] Already running'); return; } @@ -250,7 +269,7 @@ export class UsageMonitor extends EventEmitter { const settings = profileManager.getAutoSwitchSettings(); const interval = settings.usageCheckInterval || 30000; // 30 seconds for accurate usage tracking - console.warn('[UsageMonitor] Starting with interval:', interval, 'ms (30-second updates for accurate usage stats)'); + this.debugLog('[UsageMonitor] Starting with interval: ' + interval + ' ms (30-second updates for accurate usage stats)'); // Check immediately this.checkUsageAndSwap(); @@ -268,7 +287,7 @@ export class UsageMonitor extends EventEmitter { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; - console.warn('[UsageMonitor] Stopped'); + this.debugLog('[UsageMonitor] Stopped'); } } @@ -287,27 +306,114 @@ export class UsageMonitor extends EventEmitter { */ clearProfileUsageCache(profileId: string): void { const deleted = this.allProfilesUsageCache.delete(profileId); - if (this.isDebug) { - console.warn('[UsageMonitor] Cleared usage cache for profile:', { - profileId, - wasInCache: deleted + + // Also clear currentUsage if it belongs to this profile + // This prevents stale data from being displayed when getAllProfilesUsage() + // uses this.currentUsage for the active profile + const clearedCurrentUsage = this.currentUsageProfileId === profileId; + if (clearedCurrentUsage) { + this.currentUsage = null; + this.currentUsageProfileId = null; + } + + this.debugLog('[UsageMonitor] Cleared usage cache for profile:', { + profileId, + wasInCache: deleted, + clearedCurrentUsage + }); + } + + /** + * Clear a profile from the auth-failed list. + * Called after successful re-authentication to allow the profile to be used again. + * + * @param profileId - Profile identifier to clear from failed list + */ + clearAuthFailedProfile(profileId: string): void { + const wasInFailedList = this.authFailedProfiles.has(profileId); + const wasNeedsReauth = this.needsReauthProfiles.has(profileId); + this.authFailedProfiles.delete(profileId); + this.needsReauthProfiles.delete(profileId); + this.clearProfileUsageCache(profileId); + + if (wasInFailedList || wasNeedsReauth) { + this.debugLog('[UsageMonitor] Cleared auth failure status for profile: ' + profileId, { + wasInFailedList, + wasNeedsReauth }); } } + /** + * Trigger an immediate usage check. + * Called after re-authentication to give the user immediate feedback. + */ + checkNow(): void { + this.debugLog('[UsageMonitor] Immediate check triggered'); + this.checkUsageAndSwap().catch(error => { + console.error('[UsageMonitor] Immediate check failed:', error); + }); + } + /** * 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. + * + * @param forceRefresh - If true, bypasses cache and fetches fresh data for all profiles */ - async getAllProfilesUsage(): Promise { + async getAllProfilesUsage(forceRefresh: boolean = false): Promise { const profileManager = getClaudeProfileManager(); const settings = profileManager.getSettings(); const activeProfileId = settings.activeProfileId; + // CRITICAL: On startup, currentUsage may be null, but we still need to check for + // missing credentials to show the re-auth indicator. Proactively check all profiles + // for missing credentials and populate needsReauthProfiles. if (!this.currentUsage) { - return null; + // Check all OAuth profiles for missing credentials + for (const profile of settings.profiles) { + if (profile.configDir) { + const expandedConfigDir = profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, homedir()) + : profile.configDir; + const creds = getCredentialsFromKeychain(expandedConfigDir); + if (!creds.token) { + // Credentials are missing - mark for re-auth + this.needsReauthProfiles.add(profile.id); + this.debugLog('[UsageMonitor:getAllProfilesUsage] Profile needs re-auth (no credentials): ' + profile.name); + } + } + } + + // Build a minimal response with needsReauthentication flags even without usage data + const allProfiles: ProfileUsageSummary[] = settings.profiles.map(profile => ({ + profileId: profile.id, + profileName: profile.name, + profileEmail: profile.email, + sessionPercent: 0, + weeklyPercent: 0, + isAuthenticated: profile.isAuthenticated ?? false, + isRateLimited: false, + availabilityScore: profile.isAuthenticated ? 100 : 0, + isActive: profile.id === activeProfileId, + needsReauthentication: this.needsReauthProfiles.has(profile.id) + })); + + // Return minimal data with auth status - don't return null! + return { + activeProfile: { + profileId: activeProfileId || '', + profileName: settings.profiles.find(p => p.id === activeProfileId)?.name || '', + sessionPercent: 0, + weeklyPercent: 0, + fetchedAt: new Date(), + needsReauthentication: this.needsReauthProfiles.has(activeProfileId || '') + }, + allProfiles, + fetchedAt: new Date() + }; } const now = Date.now(); @@ -322,8 +428,8 @@ export class UsageMonitor extends EventEmitter { 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) { + // Use cached data if fresh (within TTL) and not force refreshing + if (!forceRefresh && cached && (now - cached.fetchedAt) < UsageMonitor.PROFILE_USAGE_CACHE_TTL_MS) { profileResults[i] = { ...cached.usage, isActive: profile.id === activeProfileId @@ -331,7 +437,7 @@ export class UsageMonitor extends EventEmitter { continue; } - // For active profile, use the current detailed usage + // For active profile, use the current detailed usage (always fresh from last poll) if (profile.id === activeProfileId && this.currentUsage) { const summary = this.buildProfileUsageSummary(profile, this.currentUsage); profileResults[i] = summary; @@ -414,7 +520,8 @@ export class UsageMonitor extends EventEmitter { profile.isAuthenticated ?? false ), isActive: profile.id === activeProfileId, - lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString() + lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString(), + needsReauthentication: this.needsReauthProfiles.has(profile.id) }; this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now }); @@ -447,20 +554,21 @@ export class UsageMonitor extends EventEmitter { /** * Fetch usage for an inactive profile using its own credentials * This allows showing real usage data for non-active profiles + * + * Uses ensureValidToken to proactively refresh tokens before making API calls, + * preventing 401 errors for inactive profiles whose tokens may have expired. */ private async fetchUsageForInactiveProfile( profile: { id: string; name: string; email?: string; configDir?: string; isAuthenticated?: boolean } ): Promise { // 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 - }); - } + this.debugLog('[UsageMonitor] Skipping inactive profile fetch - not authenticated or no configDir:', { + profileId: profile.id, + profileName: profile.name, + isAuthenticated: profile.isAuthenticated, + hasConfigDir: !!profile.configDir + }); return null; } @@ -470,40 +578,92 @@ export class UsageMonitor extends EventEmitter { ? profile.configDir.replace(/^~/, homedir()) : profile.configDir; - const keychainCreds = getCredentialsFromKeychain(expandedConfigDir); + // Use ensureValidToken to proactively refresh the token if near expiry + // This is critical for inactive profiles whose tokens may have expired + let token: string | null = null; + let wasRefreshed = false; - if (!keychainCreds.token) { - if (this.isDebug) { - console.warn('[UsageMonitor] No keychain credentials for inactive profile:', profile.name); + try { + const tokenResult = await ensureValidToken(expandedConfigDir); + + if (tokenResult.wasRefreshed) { + this.debugLog('[UsageMonitor] Proactively refreshed token for inactive profile: ' + profile.name, { + tokenFingerprint: getCredentialFingerprint(tokenResult.token) + }); + wasRefreshed = true; + + // Check if token refresh succeeded but persistence failed + // The token works for this session but will be lost on restart + if (tokenResult.persistenceFailed) { + console.warn('[UsageMonitor] Token refreshed but persistence failed for profile: ' + profile.name + + ' - user should re-authenticate to avoid auth errors on next restart'); + this.needsReauthProfiles.add(profile.id); + } else { + // Token was refreshed and persisted successfully - clear from needsReauth if present + this.needsReauthProfiles.delete(profile.id); + } } - return null; + + token = tokenResult.token; + + if (tokenResult.error) { + this.debugLog('[UsageMonitor] Token validation failed for inactive profile: ' + profile.name, tokenResult.error); + + // Check for invalid_grant error - indicates refresh token is invalid + // and user needs to manually re-authenticate + if (tokenResult.errorCode === 'invalid_grant') { + this.debugLog('[UsageMonitor] Profile needs re-authentication (invalid refresh token): ' + profile.name); + this.needsReauthProfiles.add(profile.id); + } + + // Check for missing_credentials error - indicates no token in credential store + // User needs to authenticate via /login + if (tokenResult.errorCode === 'missing_credentials') { + this.debugLog('[UsageMonitor] Profile needs authentication (no credentials found): ' + profile.name); + this.needsReauthProfiles.add(profile.id); + } + } + } catch (error) { + this.debugLog('[UsageMonitor] ensureValidToken failed for inactive profile: ' + profile.name, error); } - if (this.isDebug) { - console.warn('[UsageMonitor] Fetching usage for inactive profile:', { - profileId: profile.id, - profileName: profile.name, - tokenFingerprint: getCredentialFingerprint(keychainCreds.token) - }); + // Fallback: Try direct keychain read if ensureValidToken failed + if (!token) { + const keychainCreds = getCredentialsFromKeychain(expandedConfigDir); + token = keychainCreds.token; + + if (!token) { + this.debugLog('[UsageMonitor] No keychain credentials for inactive profile: ' + profile.name); + // Mark profile as needing re-authentication since credentials are missing + this.needsReauthProfiles.add(profile.id); + return null; + } } + this.debugLog('[UsageMonitor] Fetching usage for inactive profile:', { + profileId: profile.id, + profileName: profile.name, + tokenFingerprint: getCredentialFingerprint(token), + wasRefreshed + }); + // Fetch usage via API - OAuth profiles always use Anthropic const usage = await this.fetchUsageViaAPI( - keychainCreds.token, + token, profile.id, profile.name, - keychainCreds.email ?? profile.email, + profile.email, { profileId: profile.id, profileName: profile.name, - profileEmail: keychainCreds.email ?? profile.email, + profileEmail: profile.email, isAPIProfile: false, baseUrl: 'https://api.anthropic.com' } ); - if (this.isDebug && usage) { - console.warn('[UsageMonitor] Successfully fetched inactive profile usage:', { + if (usage) { + this.debugLog('[UsageMonitor] Successfully fetched inactive profile usage:', { profileName: profile.name, sessionPercent: usage.sessionPercent, weeklyPercent: usage.weeklyPercent @@ -512,7 +672,7 @@ export class UsageMonitor extends EventEmitter { return usage; } catch (error) { - console.warn('[UsageMonitor] Failed to fetch inactive profile usage:', profile.name, error); + this.debugLog('[UsageMonitor] Failed to fetch inactive profile usage: ' + profile.name, error); return null; } } @@ -547,7 +707,8 @@ export class UsageMonitor extends EventEmitter { profile.isAuthenticated ?? true ), isActive: usage.profileId === profileManager.getActiveProfile()?.id, - lastFetchedAt: usage.fetchedAt?.toISOString() + lastFetchedAt: usage.fetchedAt?.toISOString(), + needsReauthentication: this.needsReauthProfiles.has(profile.id) }; } @@ -615,51 +776,94 @@ export class UsageMonitor extends EventEmitter { (p) => p.id === profilesFile.activeProfileId ); if (activeProfile && activeProfile.apiKey) { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Using API profile credential:', activeProfile.name); - } + this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name); return activeProfile.apiKey; } } } catch (error) { // API profile loading failed, fall through to OAuth - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); - } + this.debugLog('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); } - // Fall back to OAuth profile - read FRESH token from Keychain + // Fall back to OAuth profile - use ensureValidToken for proactive refresh const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); 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); + // Use ensureValidToken to proactively refresh tokens before they expire + // This prevents 401 errors during overnight autonomous operation + try { + const tokenResult = await ensureValidToken(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) + if (tokenResult.wasRefreshed) { + this.debugLog('[UsageMonitor] Proactively refreshed token for profile: ' + activeProfile.name, { + tokenFingerprint: getCredentialFingerprint(tokenResult.token) }); + + // Check if token refresh succeeded but persistence failed + // The token works for this session but will be lost on restart + if (tokenResult.persistenceFailed) { + console.warn('[UsageMonitor] Token refreshed but persistence failed for profile: ' + activeProfile.name + + ' - user should re-authenticate to avoid auth errors on next restart'); + this.needsReauthProfiles.add(activeProfile.id); + } else { + // Token was refreshed and persisted successfully - clear from needsReauth if present + this.needsReauthProfiles.delete(activeProfile.id); + } } + + if (tokenResult.token) { + this.debugLog('[UsageMonitor:TRACE] Using OAuth token for profile: ' + activeProfile.name, { + tokenFingerprint: getCredentialFingerprint(tokenResult.token), + wasRefreshed: tokenResult.wasRefreshed + }); + return tokenResult.token; + } + + // Token unavailable - log the error + if (tokenResult.error) { + this.debugLog('[UsageMonitor] Token validation failed:', tokenResult.error); + + // Check for invalid_grant error - indicates refresh token is permanently invalid + // and user needs to manually re-authenticate + if (tokenResult.errorCode === 'invalid_grant') { + this.debugLog('[UsageMonitor] Profile needs re-authentication (invalid refresh token): ' + activeProfile.name); + this.needsReauthProfiles.add(activeProfile.id); + } + + // Check for missing_credentials error - indicates no token in credential store + // User needs to authenticate via /login + if (tokenResult.errorCode === 'missing_credentials') { + this.debugLog('[UsageMonitor] Profile needs authentication (no credentials found): ' + activeProfile.name); + this.needsReauthProfiles.add(activeProfile.id); + } + } + } catch (error) { + console.error('[UsageMonitor] ensureValidToken threw error:', error); + } + + // Fallback: Try direct keychain read (e.g., if refresh token unavailable) + const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir); + if (keychainCreds.token) { + this.debugLog('[UsageMonitor:TRACE] Using fallback 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) + // Keychain read also failed 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'); + this.debugLog('[UsageMonitor] Keychain access failed:', keychainCreds.error); + } else { + this.debugLog('[UsageMonitor:TRACE] No token in Keychain for profile: ' + activeProfile.name + + ' - user may need to re-authenticate with claude /login'); } + + // Mark profile as needing re-authentication since credentials are missing + this.needsReauthProfiles.add(activeProfile.id); } // No credential available - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)'); - } + this.debugLog('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)'); return undefined; } @@ -694,11 +898,15 @@ export class UsageMonitor extends EventEmitter { const credential = await this.getCredential(); const usage = await this.fetchUsage(profileId, credential, activeProfile); if (!usage) { - console.warn('[UsageMonitor] Failed to fetch usage'); + this.debugLog('[UsageMonitor] Failed to fetch usage'); return; } + // Add needsReauthentication flag to the snapshot for the active profile + usage.needsReauthentication = this.needsReauthProfiles.has(profileId); + 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(); @@ -719,25 +927,21 @@ export class UsageMonitor extends EventEmitter { const settings = profileManager.getAutoSwitchSettings(); if (!settings.enabled || !settings.proactiveSwapEnabled) { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Proactive swap disabled, skipping threshold check'); - } + this.debugLog('[UsageMonitor:TRACE] Proactive swap disabled, skipping threshold check'); return; } const thresholds = this.checkThresholdsExceeded(usage, settings); if (thresholds.anyExceeded) { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Threshold exceeded', { - sessionPercent: usage.sessionPercent, - weekPercent: usage.weeklyPercent, - activeProfile: profileId, - hasCredential: !!credential - }); - } + this.debugLog('[UsageMonitor:TRACE] Threshold exceeded', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent, + activeProfile: profileId, + hasCredential: !!credential + }); - console.warn('[UsageMonitor] Threshold exceeded:', { + this.debugLog('[UsageMonitor] Threshold exceeded:', { sessionPercent: usage.sessionPercent, sessionThreshold: settings.sessionThreshold ?? 95, weeklyPercent: usage.weeklyPercent, @@ -750,17 +954,13 @@ export class UsageMonitor extends EventEmitter { thresholds.sessionExceeded ? 'session' : 'weekly' ); } else { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Usage OK', { - sessionPercent: usage.sessionPercent, - weekPercent: usage.weeklyPercent - }); - } + this.debugLog('[UsageMonitor:TRACE] Usage OK', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent + }); } } else { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Skipping proactive swap for API profile (only supported for OAuth profiles)'); - } + this.debugLog('[UsageMonitor:TRACE] Skipping proactive swap for API profile (only supported for OAuth profiles)'); } } catch (error) { // Step 5: Handle auth failures @@ -809,13 +1009,11 @@ export class UsageMonitor extends EventEmitter { ); if (activeAPIProfile?.apiKey) { // API profile is active and has an apiKey - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Active auth type: API Profile', { - profileId: activeAPIProfile.id, - profileName: activeAPIProfile.name, - baseUrl: activeAPIProfile.baseUrl - }); - } + this.debugLog('[UsageMonitor:TRACE] Active auth type: API Profile', { + profileId: activeAPIProfile.id, + profileName: activeAPIProfile.name, + baseUrl: activeAPIProfile.baseUrl + }); return { profileId: activeAPIProfile.id, profileName: activeAPIProfile.name, @@ -824,24 +1022,18 @@ export class UsageMonitor extends EventEmitter { }; } else if (activeAPIProfile) { // API profile exists but missing apiKey - fall back to OAuth - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Active API profile missing apiKey, falling back to OAuth', { - profileId: activeAPIProfile.id, - profileName: activeAPIProfile.name - }); - } + this.debugLog('[UsageMonitor:TRACE] Active API profile missing apiKey, falling back to OAuth', { + profileId: activeAPIProfile.id, + profileName: activeAPIProfile.name + }); } else { // activeProfileId is set but profile not found - fall through to OAuth - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Active API profile ID set but profile not found, falling back to OAuth'); - } + this.debugLog('[UsageMonitor:TRACE] Active API profile ID set but profile not found, falling back to OAuth'); } } } catch (error) { // Failed to load API profiles - fall through to OAuth - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); - } + this.debugLog('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); } // If no API profile is active, check OAuth profiles @@ -849,7 +1041,7 @@ export class UsageMonitor extends EventEmitter { const activeOAuthProfile = profileManager.getActiveProfile(); if (!activeOAuthProfile) { - console.warn('[UsageMonitor] No active profile (neither API nor OAuth)'); + this.debugLog('[UsageMonitor] No active profile (neither API nor OAuth)'); return null; } @@ -862,13 +1054,11 @@ export class UsageMonitor extends EventEmitter { profileEmail = keychainCreds.email ?? undefined; } - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', { - profileId: activeOAuthProfile.id, - profileName: activeOAuthProfile.name, - profileEmail - }); - } + this.debugLog('[UsageMonitor:TRACE] Active auth type: OAuth Profile', { + profileId: activeOAuthProfile.id, + profileName: activeOAuthProfile.name, + profileEmail + }); const result = { profileId: activeOAuthProfile.id, @@ -903,20 +1093,60 @@ export class UsageMonitor extends EventEmitter { } /** - * Handle auth failure by marking profile as failed and attempting proactive swap + * Handle auth failure by attempting token refresh, then marking profile as failed + * and attempting proactive swap if refresh fails. * * @param profileId - Profile that failed auth - * @param isAPIProfile - Whether this is an API profile (proactive swap only for OAuth) + * @param isAPIProfile - Whether this is an API profile (token refresh only for OAuth) */ private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise { 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 + // For OAuth profiles, attempt token refresh before giving up if (!isAPIProfile) { const profile = profileManager.getProfile(profileId); if (profile?.configDir) { - console.warn('[UsageMonitor] Auth failure - clearing keychain cache for profile:', profileId); + this.debugLog('[UsageMonitor] Auth failure - attempting token refresh for profile: ' + profileId); + + try { + const refreshResult = await reactiveTokenRefresh(profile.configDir); + + if (refreshResult.wasRefreshed && refreshResult.token) { + this.debugLog('[UsageMonitor] Token refresh successful for profile: ' + profileId, { + tokenFingerprint: getCredentialFingerprint(refreshResult.token) + }); + + // Check if token refresh succeeded but persistence failed + // The token works for this session but will be lost on restart + if (refreshResult.persistenceFailed) { + console.warn('[UsageMonitor] Token refreshed but persistence failed for profile: ' + profileId + + ' - user should re-authenticate to avoid auth errors on next restart'); + this.needsReauthProfiles.add(profileId); + } else { + // Token was refreshed and persisted successfully - clear from needsReauth if present + this.needsReauthProfiles.delete(profileId); + } + + // Token was refreshed - don't mark as failed, let next poll use the new token + return; + } + + if (refreshResult.error) { + this.debugLog('[UsageMonitor] Token refresh failed:', refreshResult.error); + + // Check for invalid_grant error - indicates refresh token is permanently invalid + // and user needs to manually re-authenticate (matches inactive profile handling) + if (refreshResult.errorCode === 'invalid_grant') { + this.debugLog('[UsageMonitor] Profile needs re-authentication (invalid refresh token): ' + profileId); + this.needsReauthProfiles.add(profileId); + } + } + } catch (refreshError) { + console.error('[UsageMonitor] Token refresh threw error:', refreshError); + } + + // Refresh failed - clear cache so next attempt gets fresh credentials + this.debugLog('[UsageMonitor] Auth failure - clearing keychain cache for profile: ' + profileId); clearKeychainCache(profile.configDir); } } @@ -925,13 +1155,13 @@ export class UsageMonitor extends EventEmitter { // Proactive swap is only supported for OAuth profiles, not API profiles if (isAPIProfile || !settings.enabled || !settings.proactiveSwapEnabled) { - console.warn('[UsageMonitor] Auth failure detected but proactive swap is disabled or using API profile, skipping swap'); + this.debugLog('[UsageMonitor] Auth failure detected but proactive swap is disabled or using API profile, skipping swap'); return; } // Mark this profile as auth-failed to prevent swap loops this.authFailedProfiles.set(profileId, Date.now()); - console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', profileId); + this.debugLog('[UsageMonitor] Auth failure detected, marked profile as failed: ' + profileId); // Clean up expired entries from the failed profiles map const now = Date.now(); @@ -943,7 +1173,7 @@ export class UsageMonitor extends EventEmitter { try { const excludeProfiles = Array.from(this.authFailedProfiles.keys()); - console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); + this.debugLog('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); await this.performProactiveSwap( profileId, 'session', // Treat auth failure as session limit for immediate swap @@ -979,14 +1209,12 @@ export class UsageMonitor extends EventEmitter { if (activeProfile?.profileName) { profileName = activeProfile.profileName; profileEmail = activeProfile.profileEmail; - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Using activeProfile data:', { - profileId, - profileName, - profileEmail, - isAPIProfile: activeProfile.isAPIProfile - }); - } + this.debugLog('[UsageMonitor:FETCH] Using activeProfile data:', { + profileId, + profileName, + profileEmail, + isAPIProfile: activeProfile.isAPIProfile + }); } // Only search API profiles if not already set from activeProfile @@ -996,19 +1224,15 @@ export class UsageMonitor extends EventEmitter { 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 - }); - } + this.debugLog('[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); - } + this.debugLog('[UsageMonitor:FETCH] Failed to load API profiles:', error); } } @@ -1022,65 +1246,51 @@ export class UsageMonitor extends EventEmitter { if (!profileEmail) { profileEmail = oauthProfile.email; } - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Found OAuth profile:', { - profileId, - profileName, - profileEmail - }); - } + this.debugLog('[UsageMonitor:FETCH] Found OAuth profile:', { + profileId, + profileName, + profileEmail + }); } } // If still not found, return null if (!profileName) { - console.warn('[UsageMonitor:FETCH] Profile not found in either API or OAuth profiles:', profileId); + this.debugLog('[UsageMonitor:FETCH] Profile not found in either API or OAuth profiles: ' + profileId); return null; } - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Starting usage fetch:', { - profileId, - profileName, - hasCredential: !!credential, - useApiMethod: this.shouldUseApiMethod(profileId) - }); - } + this.debugLog('[UsageMonitor:FETCH] Starting usage fetch:', { + profileId, + profileName, + hasCredential: !!credential, + useApiMethod: this.shouldUseApiMethod(profileId) + }); // Attempt 1: Direct API call (preferred) // Per-profile tracking: if API fails for one profile, it only affects that profile if (this.shouldUseApiMethod(profileId) && credential) { - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Attempting API fetch method'); - } + this.debugLog('[UsageMonitor:FETCH] Attempting API fetch method'); const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, profileEmail, activeProfile); if (apiUsage) { - console.warn('[UsageMonitor] Successfully fetched via API'); - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] API fetch successful:', { - sessionPercent: apiUsage.sessionPercent, - weeklyPercent: apiUsage.weeklyPercent - }); - } + this.debugLog('[UsageMonitor] Successfully fetched via API'); + this.debugLog('[UsageMonitor:FETCH] API fetch successful:', { + sessionPercent: apiUsage.sessionPercent, + weeklyPercent: apiUsage.weeklyPercent + }); return apiUsage; } // API failed - record timestamp for cooldown-based retry - console.warn('[UsageMonitor] API method failed, recording failure timestamp for cooldown retry'); - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] API fetch failed, will retry after cooldown'); - } + this.debugLog('[UsageMonitor] API method failed, recording failure timestamp for cooldown retry'); + this.debugLog('[UsageMonitor:FETCH] API fetch failed, will retry after cooldown'); this.apiFailureTimestamps.set(profileId, Date.now()); } else if (!credential) { - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] No credential available, skipping API method'); - } + this.debugLog('[UsageMonitor:FETCH] No credential available, skipping API method'); } // Attempt 2: CLI /usage command (fallback) - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Attempting CLI fallback method'); - } + this.debugLog('[UsageMonitor:FETCH] Attempting CLI fallback method'); return await this.fetchUsageViaCLI(profileId, profileName); } @@ -1109,14 +1319,12 @@ export class UsageMonitor extends EventEmitter { profileEmail?: string, activeProfile?: ActiveProfileResult ): Promise { - if (this.isDebug) { - console.warn('[UsageMonitor:API_FETCH] Starting API fetch for usage:', { - profileId, - profileName, - hasCredential: !!credential, - hasActiveProfile: !!activeProfile - }); - } + this.debugLog('[UsageMonitor:API_FETCH] Starting API fetch for usage:', { + profileId, + profileName, + hasCredential: !!credential, + hasActiveProfile: !!activeProfile + }); try { // Step 1: Determine if we're using an API profile or OAuth profile @@ -1150,20 +1358,18 @@ export class UsageMonitor extends EventEmitter { } } - if (this.isDebug) { - const isAPIProfile = !!apiProfile; - console.warn('[UsageMonitor:TRACE] Fetching usage', { - provider, - baseUrl, - isAPIProfile, - profileId - }); - } + const isAPIProfile = !!apiProfile; + this.debugLog('[UsageMonitor:TRACE] Fetching usage', { + provider, + baseUrl, + isAPIProfile, + profileId + }); // Step 3: Get provider-specific usage endpoint const usageEndpoint = getUsageEndpoint(provider, baseUrl); if (!usageEndpoint) { - console.warn('[UsageMonitor] Unknown provider - no usage endpoint configured:', { + this.debugLog('[UsageMonitor] Unknown provider - no usage endpoint configured:', { provider, baseUrl, profileId @@ -1171,21 +1377,17 @@ export class UsageMonitor extends EventEmitter { return null; } - if (this.isDebug) { - console.warn('[UsageMonitor:API_FETCH] API request:', { - endpoint: usageEndpoint, - profileId, - credentialFingerprint: getCredentialFingerprint(credential) - }); - } + this.debugLog('[UsageMonitor:API_FETCH] API request:', { + endpoint: usageEndpoint, + profileId, + credentialFingerprint: getCredentialFingerprint(credential) + }); - if (this.isDebug) { - console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', { - provider, - endpoint: usageEndpoint, - hasCredential: !!credential - }); - } + this.debugLog('[UsageMonitor:API_FETCH] Fetching from endpoint:', { + provider, + endpoint: usageEndpoint, + hasCredential: !!credential + }); // Step 4: Validate endpoint domain before making request // Security: Only allow requests to known provider domains @@ -1248,25 +1450,21 @@ export class UsageMonitor extends EventEmitter { errorData = await response.json(); } catch (parseError) { // If we can't parse the error response, just log it and continue - if (this.isDebug) { - console.warn('[UsageMonitor:AUTH_DETECTION] Could not parse error response body:', { - provider, - status: response.status, - parseError - }); - } + this.debugLog('[UsageMonitor:AUTH_DETECTION] Could not parse error response body:', { + provider, + status: response.status, + parseError + }); // Record failure timestamp for cooldown retry this.apiFailureTimestamps.set(profileId, Date.now()); return null; } - if (this.isDebug) { - console.warn('[UsageMonitor:AUTH_DETECTION] Checking error response for auth failure:', { - provider, - status: response.status, - errorData - }); - } + this.debugLog('[UsageMonitor:AUTH_DETECTION] Checking error response for auth failure:', { + provider, + status: response.status, + errorData + }); // Check for common auth error patterns in response body const authErrorPatterns = [ @@ -1296,20 +1494,16 @@ export class UsageMonitor extends EventEmitter { return null; } - if (this.isDebug) { - console.warn('[UsageMonitor:API_FETCH] API response received successfully:', { - provider, - status: response.status, - contentType: response.headers.get('content-type') - }); - } + this.debugLog('[UsageMonitor:API_FETCH] API response received successfully:', { + provider, + status: response.status, + contentType: response.headers.get('content-type') + }); // Step 5: Parse and normalize response based on provider const rawData = await response.json(); - if (this.isDebug) { - console.warn('[UsageMonitor:PROVIDER] Raw response from', provider, ':', JSON.stringify(rawData, null, 2)); - } + this.debugLog('[UsageMonitor:PROVIDER] Raw response from ' + provider + ':', JSON.stringify(rawData, null, 2)); // Step 6: Extract data wrapper for z.ai and ZHIPU responses // These providers wrap the actual usage data in a 'data' field @@ -1317,31 +1511,25 @@ export class UsageMonitor extends EventEmitter { if (provider === 'zai' || provider === 'zhipu') { if (rawData.data) { responseData = rawData.data; - if (this.isDebug) { - console.warn('[UsageMonitor:PROVIDER] Extracted data field from response:', { - provider, - extractedData: JSON.stringify(responseData, null, 2) - }); - } + this.debugLog('[UsageMonitor:PROVIDER] Extracted data field from response:', { + provider, + extractedData: JSON.stringify(responseData, null, 2) + }); } else { - if (this.isDebug) { - console.warn('[UsageMonitor:PROVIDER] No data field found in response, using raw response:', { - provider, - responseKeys: Object.keys(rawData) - }); - } + this.debugLog('[UsageMonitor:PROVIDER] No data field found in response, using raw response:', { + provider, + responseKeys: Object.keys(rawData) + }); } } // Step 7: Normalize response based on provider type let normalizedUsage: ClaudeUsageSnapshot | null = null; - if (this.isDebug) { - console.warn('[UsageMonitor:NORMALIZATION] Selecting normalization method:', { - provider, - method: `normalize${provider.charAt(0).toUpperCase() + provider.slice(1)}Response` - }); - } + this.debugLog('[UsageMonitor:NORMALIZATION] Selecting normalization method:', { + provider, + method: `normalize${provider.charAt(0).toUpperCase() + provider.slice(1)}Response` + }); switch (provider) { case 'anthropic': @@ -1354,29 +1542,27 @@ export class UsageMonitor extends EventEmitter { normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName, profileEmail); break; default: - console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider); + this.debugLog('[UsageMonitor] Unsupported provider for usage normalization: ' + provider); return null; } if (!normalizedUsage) { - console.warn('[UsageMonitor] Failed to normalize response from', provider); + this.debugLog('[UsageMonitor] Failed to normalize response from ' + provider); // Record failure timestamp for cooldown retry (normalization failure) this.apiFailureTimestamps.set(profileId, Date.now()); return null; } - if (this.isDebug) { - console.warn('[UsageMonitor:API_FETCH] Fetch completed - usage:', { - profileId, - profileName, - email: normalizedUsage.profileEmail, - provider, - sessionPercent: normalizedUsage.sessionPercent, - weeklyPercent: normalizedUsage.weeklyPercent, - limitType: normalizedUsage.limitType - }); - console.warn('[UsageMonitor:API_FETCH] API fetch completed successfully'); - } + this.debugLog('[UsageMonitor:API_FETCH] Fetch completed - usage:', { + profileId, + profileName, + email: normalizedUsage.profileEmail, + provider, + sessionPercent: normalizedUsage.sessionPercent, + weeklyPercent: normalizedUsage.weeklyPercent, + limitType: normalizedUsage.limitType + }); + this.debugLog('[UsageMonitor:API_FETCH] API fetch completed successfully'); return normalizedUsage; } catch (error: any) { @@ -1666,7 +1852,7 @@ export class UsageMonitor extends EventEmitter { // CLI-based usage fetching is not implemented yet. // The API method should handle most cases. If we need CLI fallback, // we would need to spawn a Claude process with /usage command and parse the output. - console.warn('[UsageMonitor] CLI fallback not implemented, API method should be used'); + this.debugLog('[UsageMonitor] CLI fallback not implemented, API method should be used'); return null; } @@ -1731,13 +1917,11 @@ export class UsageMonitor extends EventEmitter { } } } catch (error) { - if (this.isDebug) { - console.warn('[UsageMonitor] Failed to load API profiles for swap:', error); - } + this.debugLog('[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.debugLog('[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', currentProfile: currentProfileId, @@ -1763,13 +1947,17 @@ export class UsageMonitor extends EventEmitter { // Use the best available from unified accounts const bestAccount = unifiedAccounts[0]; - console.warn('[UsageMonitor] Proactive swap:', { + this.debugLog('[UsageMonitor] Proactive swap:', { from: currentProfileId, to: bestAccount.id, toType: bestAccount.type, 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 diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 04b36fdf..b5af5d94 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -46,14 +46,14 @@ import { pythonEnvManager } from './python-env-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; -import { DEFAULT_APP_SETTINGS } from '../shared/constants'; +import { DEFAULT_APP_SETTINGS, IPC_CHANNELS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; import { preWarmToolCache } from './cli-tool-manager'; -import { initializeClaudeProfileManager } from './claude-profile-manager'; +import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager'; import { isMacOS, isWindows } from './platform'; -import type { AppSettings } from '../shared/types'; +import type { AppSettings, AuthFailureInfo } from '../shared/types'; // ───────────────────────────────────────────────────────────────────────────── // Window sizing constants @@ -405,6 +405,36 @@ app.whenReady().then(() => { const usageMonitor = getUsageMonitor(); usageMonitor.start(); console.warn('[main] Usage monitor initialized and started (after profile load)'); + + // Check for migrated profiles that need re-authentication + // These profiles were moved from shared ~/.claude to isolated directories + // and need new credentials since they now use a different keychain entry + const profileManager = getClaudeProfileManager(); + const migratedProfileIds = profileManager.getMigratedProfileIds(); + const activeProfile = profileManager.getActiveProfile(); + + if (migratedProfileIds.length > 0) { + console.warn('[main] Found migrated profiles that need re-authentication:', migratedProfileIds); + + // If the active profile was migrated, show auth failure modal immediately + if (migratedProfileIds.includes(activeProfile.id)) { + // Wait for renderer to be ready before sending the event + mainWindow.webContents.once('did-finish-load', () => { + // Small delay to ensure stores are initialized + setTimeout(() => { + const authFailureInfo: AuthFailureInfo = { + profileId: activeProfile.id, + profileName: activeProfile.name, + failureType: 'missing', + message: `Profile "${activeProfile.name}" was migrated to an isolated directory and needs re-authentication.`, + detectedAt: new Date() + }; + console.warn('[main] Sending auth failure for migrated active profile:', activeProfile.name); + mainWindow?.webContents.send(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo); + }, 1000); + }); + } + } } }) .catch((error) => { diff --git a/apps/frontend/src/main/insights/config.ts b/apps/frontend/src/main/insights/config.ts index 553a171e..a7b8d8c7 100644 --- a/apps/frontend/src/main/insights/config.ts +++ b/apps/frontend/src/main/insights/config.ts @@ -1,6 +1,6 @@ import path from 'path'; import { existsSync, readFileSync } from 'fs'; -import { getProfileEnv } from '../rate-limit-detector'; +import { getBestAvailableProfileEnv } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; import { getOAuthModeClearVars } from '../agent/env-utils'; import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager'; @@ -109,7 +109,9 @@ export class InsightsConfig { */ async getProcessEnv(): Promise> { const autoBuildEnv = this.loadAutoBuildEnv(); - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; const apiProfileEnv = await getAPIProfileEnv(); const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv); const pythonEnv = pythonEnvManager.getPythonEnv(); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts b/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts index 0ffd9fa2..d0602300 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockGetAPIProfileEnv = vi.fn(); const mockGetOAuthModeClearVars = vi.fn(); const mockGetPythonEnv = vi.fn(); -const mockGetProfileEnv = vi.fn(); +const mockGetBestAvailableProfileEnv = vi.fn(); vi.mock('../../../../services/profile', () => ({ getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args), @@ -20,7 +20,7 @@ vi.mock('../../../../python-env-manager', () => ({ })); vi.mock('../../../../rate-limit-detector', () => ({ - getProfileEnv: () => mockGetProfileEnv(), + getBestAvailableProfileEnv: () => mockGetBestAvailableProfileEnv(), })); import { getRunnerEnv } from '../runner-env'; @@ -35,8 +35,13 @@ describe('getRunnerEnv', () => { PYTHONNOUSERSITE: '1', PYTHONPATH: '/bundled/site-packages', }); - // Default mock for profile env - returns empty by default - mockGetProfileEnv.mockReturnValue({}); + // Default mock for profile env - returns BestProfileEnvResult format + mockGetBestAvailableProfileEnv.mockReturnValue({ + env: {}, + profileId: 'default', + profileName: 'Default', + wasSwapped: false + }); }); it('merges Python env with API profile env and OAuth clear vars', async () => { @@ -93,8 +98,11 @@ describe('getRunnerEnv', () => { it('includes profileEnv for OAuth token (fixes #563)', async () => { mockGetAPIProfileEnv.mockResolvedValue({}); mockGetOAuthModeClearVars.mockReturnValue({}); - mockGetProfileEnv.mockReturnValue({ - CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123', + mockGetBestAvailableProfileEnv.mockReturnValue({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false }); const result = await getRunnerEnv(); @@ -110,8 +118,11 @@ describe('getRunnerEnv', () => { SHARED_VAR: 'from-api-profile', }); mockGetOAuthModeClearVars.mockReturnValue({}); - mockGetProfileEnv.mockReturnValue({ - SHARED_VAR: 'from-profile', + mockGetBestAvailableProfileEnv.mockReturnValue({ + env: { SHARED_VAR: 'from-profile' }, + profileId: 'default', + profileName: 'Default', + wasSwapped: false }); const result = await getRunnerEnv({ SHARED_VAR: 'from-extra' }); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts b/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts index dd05c336..a57bd2d7 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts @@ -1,6 +1,6 @@ import { getOAuthModeClearVars } from '../../../agent/env-utils'; import { getAPIProfileEnv } from '../../../services/profile'; -import { getProfileEnv } from '../../../rate-limit-detector'; +import { getBestAvailableProfileEnv } from '../../../rate-limit-detector'; import { pythonEnvManager } from '../../../python-env-manager'; import { getGitHubTokenForSubprocess } from '../utils'; @@ -34,7 +34,9 @@ export async function getRunnerEnv( const pythonEnv = pythonEnvManager.getPythonEnv(); const apiProfileEnv = await getAPIProfileEnv(); const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv); - const profileEnv = getProfileEnv(); + // Get best available Claude profile environment (automatically handles rate limits) + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; // Fetch fresh GitHub token from gh CLI (no caching to reflect account changes) const githubToken = await getGitHubTokenForSubprocess(); @@ -44,7 +46,7 @@ export async function getRunnerEnv( ...pythonEnv, // Python environment including PYTHONPATH (fixes #139) ...apiProfileEnv, ...oauthModeClearVars, - ...profileEnv, // OAuth token from profile manager (fixes #563) + ...profileEnv, // OAuth token from profile manager (fixes #563, rate-limit aware) ...githubEnv, // Fresh GitHub token from gh CLI (fixes #151) ...extraEnv, }; diff --git a/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.test.ts b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.test.ts new file mode 100644 index 00000000..82db4f58 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.test.ts @@ -0,0 +1,376 @@ +/** + * Tests for Queue Routing IPC Handlers + * + * Tests the IPC communication for rate limit recovery queue routing. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain, BrowserWindow } from 'electron'; +import { registerQueueRoutingHandlers } from './queue-routing-handlers'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { AgentManager } from '../agent/agent-manager'; +import type { ProfileAssignmentReason } from '../../shared/types'; + +// Mock Electron +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn() + }, + BrowserWindow: vi.fn() +})); + +describe('registerQueueRoutingHandlers', () => { + let mockAgentManager: Partial; + let mockWindow: Partial; + let getMainWindow: () => BrowserWindow | null; + let registeredHandlers: Map; + let registeredEventListeners: Map; + + beforeEach(() => { + vi.clearAllMocks(); + registeredHandlers = new Map(); + registeredEventListeners = new Map(); + + // Capture registered handlers + (ipcMain.handle as ReturnType).mockImplementation( + (channel: string, handler: Function) => { + registeredHandlers.set(channel, handler); + } + ); + + // Setup mock agent manager - use unknown intermediate cast to avoid partial type issues + const onMock = vi.fn((event: string, handler: Function) => { + registeredEventListeners.set(event, handler); + return mockAgentManager as AgentManager; + }); + + mockAgentManager = { + getRunningTasksByProfile: vi.fn(() => ({ + byProfile: { 'profile-1': ['task-1', 'task-2'] }, + totalRunning: 2 + })), + assignProfileToTask: vi.fn(), + getTaskProfileAssignment: vi.fn(() => ({ + profileId: 'profile-1', + profileName: 'Profile 1', + reason: 'proactive' as ProfileAssignmentReason + })), + updateTaskSession: vi.fn(), + getTaskSessionId: vi.fn(() => 'session-123'), + on: onMock as unknown as AgentManager['on'] + }; + + // Setup mock window + mockWindow = { + webContents: { + send: vi.fn() + } as unknown as Electron.WebContents + }; + + getMainWindow = () => mockWindow as BrowserWindow; + }); + + afterEach(() => { + registeredHandlers.clear(); + registeredEventListeners.clear(); + }); + + it('should register all IPC handlers', () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE, + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION, + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_GET_TASK_SESSION, + expect.any(Function) + ); + }); + + describe('getRunningTasksByProfile handler', () => { + it('should return running tasks grouped by profile', async () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE + ); + const result = await handler?.(); + + expect(result).toEqual({ + success: true, + data: { + byProfile: { 'profile-1': ['task-1', 'task-2'] }, + totalRunning: 2 + } + }); + }); + + it('should return error on failure', async () => { + mockAgentManager.getRunningTasksByProfile = vi.fn(() => { + throw new Error('Test error'); + }); + + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE + ); + const result = await handler?.(); + + expect(result).toEqual({ + success: false, + error: 'Test error' + }); + }); + }); + + describe('getBestProfileForTask handler', () => { + it('should return null when no preference', async () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK + ); + const result = await handler?.({}, { excludeProfileId: 'profile-1' }); + + expect(result).toEqual({ + success: true, + data: null + }); + }); + }); + + describe('assignProfileToTask handler', () => { + it('should assign profile to task', async () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK + ); + const result = await handler?.( + {}, + 'task-1', + 'profile-1', + 'Profile 1', + 'proactive' + ); + + expect(mockAgentManager.assignProfileToTask).toHaveBeenCalledWith( + 'task-1', + 'profile-1', + 'Profile 1', + 'proactive' + ); + expect(result).toEqual({ success: true }); + }); + + it('should return error on failure', async () => { + mockAgentManager.assignProfileToTask = vi.fn(() => { + throw new Error('Assignment failed'); + }); + + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK + ); + const result = await handler?.( + {}, + 'task-1', + 'profile-1', + 'Profile 1', + 'proactive' + ); + + expect(result).toEqual({ + success: false, + error: 'Assignment failed' + }); + }); + }); + + describe('updateTaskSession handler', () => { + it('should update task session', async () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION + ); + const result = await handler?.({}, 'task-1', 'session-abc'); + + expect(mockAgentManager.updateTaskSession).toHaveBeenCalledWith( + 'task-1', + 'session-abc' + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('getTaskSession handler', () => { + it('should return session ID', async () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_GET_TASK_SESSION + ); + const result = await handler?.({}, 'task-1'); + + expect(result).toEqual({ + success: true, + data: 'session-123' + }); + }); + + it('should return null when no session', async () => { + mockAgentManager.getTaskSessionId = vi.fn(() => undefined); + + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const handler = registeredHandlers.get( + IPC_CHANNELS.QUEUE_GET_TASK_SESSION + ); + const result = await handler?.({}, 'task-1'); + + expect(result).toEqual({ + success: true, + data: null + }); + }); + }); + + describe('event forwarding', () => { + it('should register event listeners on agent manager', () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + expect(mockAgentManager.on).toHaveBeenCalledWith( + 'profile-swapped', + expect.any(Function) + ); + expect(mockAgentManager.on).toHaveBeenCalledWith( + 'session-captured', + expect.any(Function) + ); + expect(mockAgentManager.on).toHaveBeenCalledWith( + 'queue-blocked-no-profiles', + expect.any(Function) + ); + }); + + it('should forward profile-swapped event to renderer', () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const swapHandler = registeredEventListeners.get('profile-swapped'); + const swapData = { + fromProfileId: 'p1', + toProfileId: 'p2', + reason: 'rate_limit' + }; + + swapHandler?.('task-1', swapData); + + expect(mockWindow.webContents?.send).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, + { taskId: 'task-1', swap: swapData } + ); + }); + + it('should forward session-captured event to renderer', () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const sessionHandler = registeredEventListeners.get('session-captured'); + sessionHandler?.('task-1', 'session-abc'); + + expect(mockWindow.webContents?.send).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_SESSION_CAPTURED, + expect.objectContaining({ + taskId: 'task-1', + sessionId: 'session-abc', + capturedAt: expect.any(String) + }) + ); + }); + + it('should forward queue-blocked-no-profiles event to renderer', () => { + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + getMainWindow + ); + + const blockedHandler = registeredEventListeners.get( + 'queue-blocked-no-profiles' + ); + blockedHandler?.({ reason: 'all_rate_limited' }); + + expect(mockWindow.webContents?.send).toHaveBeenCalledWith( + IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, + expect.objectContaining({ + reason: 'all_rate_limited', + timestamp: expect.any(String) + }) + ); + }); + + it('should not send event when window is null', () => { + const nullWindowGetter = () => null; + + registerQueueRoutingHandlers( + mockAgentManager as AgentManager, + nullWindowGetter + ); + + const swapHandler = registeredEventListeners.get('profile-swapped'); + swapHandler?.('task-1', {}); + + expect(mockWindow.webContents?.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts new file mode 100644 index 00000000..2aa7e08e --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts @@ -0,0 +1,194 @@ +/** + * Queue Routing IPC Handlers + * + * Handles IPC communication for the rate limit recovery queue routing system. + * Provides profile-aware task distribution to enable overnight autonomous operation. + */ + +import { ipcMain, BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { AgentManager } from '../agent/agent-manager'; +import type { ProfileAssignmentReason, RunningTasksByProfile, ClaudeProfile } from '../../shared/types'; +import type { ClaudeProfileManager } from '../claude-profile-manager'; + +/** + * Register queue routing IPC handlers + */ +export function registerQueueRoutingHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null, + profileManager?: ClaudeProfileManager +): void { + // Get running tasks grouped by profile + ipcMain.handle( + IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE, + async (): Promise<{ success: boolean; data?: RunningTasksByProfile; error?: string }> => { + try { + const data = agentManager.getRunningTasksByProfile(); + return { success: true, data }; + } catch (error) { + console.error('[QueueRouting] Failed to get running tasks by profile:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Get best profile for a task + ipcMain.handle( + IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, + async ( + _event, + options?: { + excludeProfileId?: string; + perProfileMaxTasks?: number; + profileThreshold?: number; + } + ): Promise<{ success: boolean; data?: ClaudeProfile | null; error?: string }> => { + try { + // If no profile manager is available, return null (no preference) + if (!profileManager) { + console.log('[QueueRouting] Profile manager not available, returning null'); + return { success: true, data: null }; + } + + // Get auto-switch settings to check if enabled + const settings = profileManager.getAutoSwitchSettings(); + + // If auto-switching is disabled, return null (no preference) + if (!settings.enabled) { + console.log('[QueueRouting] Auto-switching disabled, returning null'); + return { success: true, data: null }; + } + + // Use getBestAvailableProfile which internally handles: + // - User's configured priority order + // - Profile authentication status + // - Rate limit status + // - Usage thresholds (session and weekly) + const bestProfile = profileManager.getBestAvailableProfile( + options?.excludeProfileId + ); + + if (bestProfile) { + console.log('[QueueRouting] Best profile selected:', { + profileId: bestProfile.id, + profileName: bestProfile.name, + excludedId: options?.excludeProfileId + }); + } else { + console.log('[QueueRouting] No suitable profile found for task routing'); + } + + return { success: true, data: bestProfile }; + } catch (error) { + console.error('[QueueRouting] Failed to get best profile for task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Assign a profile to a task + ipcMain.handle( + IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, + async ( + _event, + taskId: string, + profileId: string, + profileName: string, + reason: ProfileAssignmentReason + ): Promise<{ success: boolean; error?: string }> => { + try { + agentManager.assignProfileToTask(taskId, profileId, profileName, reason); + return { success: true }; + } catch (error) { + console.error('[QueueRouting] Failed to assign profile to task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Update session ID for a task + ipcMain.handle( + IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION, + async ( + _event, + taskId: string, + sessionId: string + ): Promise<{ success: boolean; error?: string }> => { + try { + agentManager.updateTaskSession(taskId, sessionId); + return { success: true }; + } catch (error) { + console.error('[QueueRouting] Failed to update task session:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Get session ID for a task + ipcMain.handle( + IPC_CHANNELS.QUEUE_GET_TASK_SESSION, + async ( + _event, + taskId: string + ): Promise<{ success: boolean; data?: string | null; error?: string }> => { + try { + const sessionId = agentManager.getTaskSessionId(taskId); + return { success: true, data: sessionId ?? null }; + } catch (error) { + console.error('[QueueRouting] Failed to get task session:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Forward events from agent manager to renderer + + // Profile swapped event + agentManager.on('profile-swapped', (taskId: string, swap: unknown) => { + const win = getMainWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, { taskId, swap }); + } + }); + + // Session captured event + agentManager.on('session-captured', (taskId: string, sessionId: string) => { + const win = getMainWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, { + taskId, + sessionId, + capturedAt: new Date().toISOString() + }); + } + }); + + // Queue blocked event (no available profiles) + agentManager.on('queue-blocked-no-profiles', (info: { reason: string }) => { + const win = getMainWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, { + ...info, + timestamp: new Date().toISOString() + }); + } + }); + + console.log('[QueueRouting] IPC handlers registered'); +} diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 8b5f7a04..af417caf 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -9,7 +9,7 @@ import { homedir } from 'os'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; import { getEffectiveSourcePath } from '../../updater/path-resolver'; -import { getProfileEnv } from '../../rate-limit-detector'; +import { getBestAvailableProfileEnv } from '../../rate-limit-detector'; import { findTaskAndProject } from './shared'; import { parsePythonCommand } from '../../python-detector'; import { getToolPath } from '../../cli-tool-manager'; @@ -1963,7 +1963,8 @@ export function registerWorktreeHandlers( debug('Working directory:', sourcePath); // Get profile environment with OAuth token for AI merge resolution - const profileEnv = getProfileEnv(); + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; debug('Profile env for merge:', { hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR @@ -2461,7 +2462,8 @@ export function registerWorktreeHandlers( console.warn('[IPC] Running merge preview:', pythonPath, args.join(' ')); // Get profile environment for consistency - const previewProfileEnv = getProfileEnv(); + const previewProfileResult = getBestAvailableProfileEnv(); + const previewProfileEnv = previewProfileResult.env; // Get Python environment for bundled packages const previewPythonEnv = pythonEnvManagerSingleton.getPythonEnv(); @@ -2981,7 +2983,8 @@ export function registerWorktreeHandlers( debug('Working directory:', sourcePath); // Get profile environment with OAuth token - const profileEnv = getProfileEnv(); + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; return new Promise((resolve) => { let timeoutId: NodeJS.Timeout | null = null; diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 705f6500..47216fbe 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron'; -import type { BrowserWindow } from 'electron'; +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types'; import { getClaudeProfileManager } from '../claude-profile-manager'; @@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator'; import { readSettingsFileAsync } from '../settings-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { migrateSession } from '../claude-profile/session-utils'; -import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils'; +import { createProfileDirectory } from '../claude-profile/profile-utils'; import { isValidConfigDir } from '../utils/config-path-validator'; @@ -242,12 +242,9 @@ export function registerTerminalHandlers( debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminals for profile change:', terminals.length); // Determine config directories for session migration - const sourceConfigDir = previousProfile.isDefault - ? DEFAULT_CLAUDE_CONFIG_DIR - : previousProfile.configDir; - const targetConfigDir = newProfile?.isDefault - ? DEFAULT_CLAUDE_CONFIG_DIR - : newProfile?.configDir; + // All profiles now have their own configDir (no special case for default) + const sourceConfigDir = previousProfile.configDir; + const targetConfigDir = newProfile?.configDir; // Build terminal refresh info for frontend const terminalsNeedingRefresh: Array<{ @@ -541,12 +538,13 @@ 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 (): Promise> => { + async (_event: IpcMainInvokeEvent, forceRefresh: boolean = false): Promise> => { try { const monitor = getUsageMonitor(); - const allProfilesUsage = await monitor.getAllProfilesUsage(); + const allProfilesUsage = await monitor.getAllProfilesUsage(forceRefresh); return { success: true, data: allProfilesUsage }; } catch (error) { return { diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index e4b619a1..497ee70c 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -6,7 +6,6 @@ import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, Implemen import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants'; import { getAutoBuildPath, isInitialized } from './project-initializer'; import { getTaskWorktreeDir } from './worktree-paths'; -import { debugLog } from '../shared/utils/debug-logger'; import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers'; interface TabState { @@ -175,7 +174,6 @@ export class ProjectStore { : null, tabOrder: tabState.tabOrder.filter(id => validProjectIds.includes(id)) }; - console.log('[ProjectStore] Saving tab state:', this.data.tabState); this.save(); } @@ -253,21 +251,13 @@ export class ProjectStore { const now = Date.now(); if (cached && (now - cached.timestamp) < this.CACHE_TTL_MS) { - console.debug('[ProjectStore] Returning cached tasks for project:', projectId, '(age:', now - cached.timestamp, 'ms)'); return cached.tasks; } - debugLog('[ProjectStore] getTasks called - will load from disk', { - projectId, - reason: cached ? 'cache expired' : 'cache miss', - cacheAge: cached ? now - cached.timestamp : 'N/A' - }); const project = this.getProject(projectId); if (!project) { - debugLog('[ProjectStore] Project not found for id:', projectId); return []; } - debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path); const allTasks: Task[] = []; const specsBaseDir = getSpecsDir(project.autoBuildPath); @@ -275,13 +265,11 @@ export class ProjectStore { // 1. Scan main project specs directory (source of truth for task existence) const mainSpecsDir = path.join(project.path, specsBaseDir); const mainSpecIds = new Set(); - console.warn('[ProjectStore] Main specsDir:', mainSpecsDir, 'exists:', existsSync(mainSpecsDir)); if (existsSync(mainSpecsDir)) { const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, project.path, 'main', projectId, specsBaseDir); allTasks.push(...mainTasks); // Track which specs exist in main project mainTasks.forEach(t => mainSpecIds.add(t.specId)); - console.warn('[ProjectStore] Loaded', mainTasks.length, 'tasks from main project'); } // 2. Scan worktree specs directories @@ -306,8 +294,6 @@ export class ProjectStore { // Only include worktree tasks if the spec exists in main project const validWorktreeTasks = worktreeTasks.filter(t => mainSpecIds.has(t.specId)); allTasks.push(...validWorktreeTasks); - const skipped = worktreeTasks.length - validWorktreeTasks.length; - console.debug('[ProjectStore] Loaded', validWorktreeTasks.length, 'tasks from worktree:', worktree.name, skipped > 0 ? `(skipped ${skipped} orphaned)` : ''); } } } catch (error) { @@ -325,11 +311,6 @@ export class ProjectStore { } const tasks = Array.from(taskMap.values()); - console.warn('[ProjectStore] Scan complete - found', tasks.length, 'unique tasks', { - mainTasks: allTasks.filter(t => t.location === 'main').length, - worktreeTasks: allTasks.filter(t => t.location === 'worktree').length, - deduplicated: allTasks.length - tasks.length - }); // Update cache this.tasksCache.set(projectId, { tasks, timestamp: now }); @@ -343,7 +324,6 @@ export class ProjectStore { */ invalidateTasksCache(projectId: string): void { this.tasksCache.delete(projectId); - console.debug('[ProjectStore] Invalidated tasks cache for project:', projectId); } /** @@ -352,7 +332,6 @@ export class ProjectStore { */ clearTasksCache(): void { this.tasksCache.clear(); - console.debug('[ProjectStore] Cleared all tasks cache'); } /** @@ -389,25 +368,15 @@ export class ProjectStore { let hasJsonError = false; let jsonErrorMessage = ''; if (existsSync(planPath)) { - console.warn(`[ProjectStore] Loading implementation_plan.json for spec: ${dir.name} from ${location}`); try { const content = readFileSync(planPath, 'utf-8'); plan = JSON.parse(content); - console.warn(`[ProjectStore] Loaded plan for ${dir.name}:`, { - hasDescription: !!plan?.description, - hasFeature: !!plan?.feature, - status: plan?.status, - phaseCount: plan?.phases?.length || 0, - subtaskCount: plan?.phases?.flatMap(p => p.subtasks || []).length || 0 - }); } catch (err) { // Don't skip - create task with error indicator so user knows it exists hasJsonError = true; jsonErrorMessage = err instanceof Error ? err.message : String(err); console.error(`[ProjectStore] JSON parse error for spec ${dir.name}:`, jsonErrorMessage); } - } else { - console.warn(`[ProjectStore] No implementation_plan.json found for spec: ${dir.name} at ${planPath}`); } // PRIORITY 1: Read description from implementation_plan.json (user's original) @@ -584,11 +553,6 @@ export class ProjectStore { if (plan?.status) { const storedStatus = statusMap[plan.status]; if (storedStatus && TERMINAL_STATUSES.has(storedStatus)) { - debugLog('[determineTaskStatusAndReason] Terminal status respected:', { - planStatus: plan.status, - mappedStatus: storedStatus, - reason: 'Terminal statuses (done, pr_created, error) are never overridden' - }); return { status: storedStatus }; } } @@ -607,21 +571,11 @@ export class ProjectStore { // During active execution, respect the stored status to prevent jumping if (isActiveProcessStatus && storedStatus === 'in_progress') { - debugLog('[determineTaskStatusAndReason] Active process status preserved:', { - planStatus: plan.status, - mappedStatus: storedStatus, - reason: 'Execution in progress - status recalculation blocked' - }); return { status: 'in_progress' }; } // Plan review stage (human approval of spec before coding starts) - // Only show plan_review when user explicitly requested human review via checkbox - if (isPlanReviewStage && storedStatus === 'human_review' && metadata?.requireReviewBeforeCoding) { - debugLog('[determineTaskStatusAndReason] Plan review stage detected:', { - planStatus: plan.status, - reason: 'Spec creation complete, awaiting user approval' - }); + if (isPlanReviewStage && storedStatus === 'human_review') { return { status: 'human_review', reviewReason: 'plan_review' }; } @@ -636,22 +590,11 @@ export class ProjectStore { } else if (allCompleted) { reviewReason = 'completed'; } - debugLog('[determineTaskStatusAndReason] Explicit human_review preserved:', { - planStatus: plan.status, - reviewReason, - hasFailedSubtasks, - allCompleted, - subtaskCount: allSubtasks.length - }); return { status: 'human_review', reviewReason }; } // Explicit ai_review status should be preserved if (storedStatus === 'ai_review') { - debugLog('[determineTaskStatusAndReason] Explicit ai_review preserved:', { - planStatus: plan.status, - subtaskCount: allSubtasks.length - }); return { status: 'ai_review' }; } } @@ -664,19 +607,11 @@ export class ProjectStore { try { const content = readFileSync(qaReportPath, 'utf-8'); if (content.includes('REJECTED') || content.includes('FAILED')) { - debugLog('[determineTaskStatusAndReason] QA report indicates rejection:', { - qaReportPath, - reason: 'QA rejected - needs human attention' - }); return { status: 'human_review', reviewReason: 'qa_rejected' }; } if (content.includes('PASSED') || content.includes('APPROVED')) { // QA passed - if all subtasks done, move to human_review if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) { - debugLog('[determineTaskStatusAndReason] QA passed with all subtasks complete:', { - qaReportPath, - subtaskCount: allSubtasks.length - }); return { status: 'human_review', reviewReason: 'completed' }; } } @@ -719,21 +654,6 @@ export class ProjectStore { } } - // Log calculated status (fallback path - no explicit status was set) - debugLog('[determineTaskStatusAndReason] Status calculated from subtasks (fallback):', { - planStatus: plan?.status || 'none', - calculatedStatus, - reviewReason, - subtaskStats: { - total: allSubtasks.length, - completed: allSubtasks.filter((s) => s.status === 'completed').length, - inProgress: allSubtasks.filter((s) => s.status === 'in_progress').length, - failed: allSubtasks.filter((s) => s.status === 'failed').length, - pending: allSubtasks.filter((s) => s.status === 'pending').length - }, - isManual: metadata?.sourceType === 'manual' - }); - return { status: calculatedStatus, reviewReason: calculatedStatus === 'human_review' ? reviewReason : undefined }; } @@ -760,7 +680,6 @@ export class ProjectStore { // If spec directory doesn't exist anywhere, skip gracefully if (specPaths.length === 0) { - console.log(`[ProjectStore] archiveTasks: Spec directory not found for ${taskId}, skipping (already removed)`); continue; } @@ -787,7 +706,6 @@ export class ProjectStore { } writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); - console.log(`[ProjectStore] archiveTasks: Successfully archived task ${taskId} at ${specPath}`); } catch (error) { console.error(`[ProjectStore] archiveTasks: Failed to archive task ${taskId} at ${specPath}:`, error); hasErrors = true; @@ -846,7 +764,6 @@ export class ProjectStore { delete metadata.archivedAt; delete metadata.archivedInVersion; writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); - console.log(`[ProjectStore] unarchiveTasks: Successfully unarchived task ${taskId} at ${specPath}`); } catch (error) { console.error(`[ProjectStore] unarchiveTasks: Failed to unarchive task ${taskId} at ${specPath}:`, error); hasErrors = true; diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index b9882559..9c8964e8 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -4,6 +4,7 @@ */ import { getClaudeProfileManager } from './claude-profile-manager'; +import { getUsageMonitor } from './claude-profile/usage-monitor'; /** * Regex pattern to detect Claude Code rate limit messages @@ -34,7 +35,7 @@ const AUTH_FAILURE_PATTERNS = [ /unauthorized/i, /please\s*(log\s*in|login|authenticate)/i, /invalid\s*(credentials|token|api\s*key)/i, - /auth(entication)?\s*(failed|error|failure)/i, + /auth(entication)?\s+(failed|error|failure)/i, /session\s*(expired|invalid)/i, /access\s*denied/i, /permission\s*denied/i, @@ -279,6 +280,188 @@ export function getProfileEnv(profileId?: string): Record { return profileManager.getActiveProfileEnv(); } +/** + * Result of getting the best available profile environment + */ +export interface BestProfileEnvResult { + /** Environment variables for the selected profile */ + env: Record; + /** 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): Record { + 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 */ diff --git a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts new file mode 100644 index 00000000..6c5f0ef2 --- /dev/null +++ b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts @@ -0,0 +1,346 @@ +/** + * Tests for SDK Session Recovery Coordinator + * + * Tests the central coordinator for SDK operations and rate limit recovery. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + SDKSessionRecoveryCoordinator, + getRecoveryCoordinator, + type SDKOperationType, + type RecoveryCoordinatorConfig +} from './sdk-session-recovery-coordinator'; + +// Mock dependencies +vi.mock('../claude-profile-manager', () => ({ + getClaudeProfileManager: vi.fn(() => ({ + getActiveProfile: vi.fn(() => ({ + id: 'profile-1', + name: 'Test Profile' + })), + getProfile: vi.fn((id: string) => ({ + id, + name: `Profile ${id}` + })) + })) +})); + +vi.mock('../claude-profile/usage-monitor', () => ({ + getUsageMonitor: vi.fn(() => ({ + getAllProfilesUsage: vi.fn(async () => ({ + activeProfile: { + profileId: 'profile-1', + profileName: 'Profile 1', + sessionPercent: 50, + weeklyPercent: 30 + }, + allProfiles: [ + { + profileId: 'profile-1', + profileName: 'Profile 1', + isAuthenticated: true, + isRateLimited: false, + availabilityScore: 70 + }, + { + profileId: 'profile-2', + profileName: 'Profile 2', + isAuthenticated: true, + isRateLimited: false, + availabilityScore: 90 + } + ], + fetchedAt: new Date() + })) + })) +})); + +vi.mock('../ipc-handlers/utils', () => ({ + safeSendToRenderer: vi.fn() +})); + +describe('SDKSessionRecoveryCoordinator', () => { + let coordinator: SDKSessionRecoveryCoordinator; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Reset singleton before each test + SDKSessionRecoveryCoordinator.resetInstance(); + coordinator = SDKSessionRecoveryCoordinator.getInstance(); + }); + + afterEach(() => { + coordinator.cleanup(); + vi.useRealTimers(); + }); + + describe('singleton pattern', () => { + it('should return same instance on multiple calls', () => { + const instance1 = SDKSessionRecoveryCoordinator.getInstance(); + const instance2 = SDKSessionRecoveryCoordinator.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('should reset instance correctly', () => { + const instance1 = SDKSessionRecoveryCoordinator.getInstance(); + SDKSessionRecoveryCoordinator.resetInstance(); + const instance2 = SDKSessionRecoveryCoordinator.getInstance(); + expect(instance1).not.toBe(instance2); + }); + }); + + describe('operation registration', () => { + it('should register a new operation', () => { + const operation = coordinator.registerOperation( + 'task-1', + 'task', + 'profile-1', + 'Test Profile' + ); + + expect(operation.id).toBe('task-1'); + expect(operation.type).toBe('task'); + expect(operation.profileId).toBe('profile-1'); + expect(operation.profileName).toBe('Test Profile'); + expect(operation.startedAt).toBeInstanceOf(Date); + }); + + it('should register operation with metadata', () => { + const metadata = { key: 'value' }; + const operation = coordinator.registerOperation( + 'task-2', + 'roadmap', + 'profile-1', + 'Test Profile', + metadata + ); + + expect(operation.metadata).toEqual(metadata); + }); + + it('should get registered operation by ID', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + + const operation = coordinator.getOperation('task-1'); + expect(operation).toBeDefined(); + expect(operation?.id).toBe('task-1'); + }); + + it('should return undefined for non-existent operation', () => { + const operation = coordinator.getOperation('non-existent'); + expect(operation).toBeUndefined(); + }); + + it('should unregister operation', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + coordinator.unregisterOperation('task-1'); + + const operation = coordinator.getOperation('task-1'); + expect(operation).toBeUndefined(); + }); + }); + + describe('session management', () => { + it('should update operation session ID', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + coordinator.updateOperationSession('task-1', 'session-abc123'); + + const operation = coordinator.getOperation('task-1'); + expect(operation?.sessionId).toBe('session-abc123'); + }); + + it('should update last activity on session update', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + const beforeUpdate = coordinator.getOperation('task-1')?.lastActivityAt; + + vi.advanceTimersByTime(1000); + coordinator.updateOperationSession('task-1', 'session-abc123'); + + const afterUpdate = coordinator.getOperation('task-1')?.lastActivityAt; + expect(afterUpdate?.getTime()).toBeGreaterThan(beforeUpdate?.getTime() ?? 0); + }); + + it('should update activity timestamp', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + const before = coordinator.getOperation('task-1')?.lastActivityAt; + + vi.advanceTimersByTime(1000); + coordinator.updateOperationActivity('task-1'); + + const after = coordinator.getOperation('task-1')?.lastActivityAt; + expect(after?.getTime()).toBeGreaterThan(before?.getTime() ?? 0); + }); + }); + + describe('getOperationsByType', () => { + it('should filter operations by type', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + coordinator.registerOperation('task-2', 'task', 'profile-1', 'Test Profile'); + coordinator.registerOperation('roadmap-1', 'roadmap', 'profile-1', 'Test Profile'); + + const tasks = coordinator.getOperationsByType('task'); + expect(tasks).toHaveLength(2); + expect(tasks.every(op => op.type === 'task')).toBe(true); + }); + + it('should return empty array when no operations of type exist', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile'); + + const ideations = coordinator.getOperationsByType('ideation'); + expect(ideations).toHaveLength(0); + }); + }); + + describe('getOperationsByProfile', () => { + it('should filter operations by profile', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + coordinator.registerOperation('task-2', 'task', 'profile-1', 'Profile 1'); + coordinator.registerOperation('task-3', 'task', 'profile-2', 'Profile 2'); + + const profile1Ops = coordinator.getOperationsByProfile('profile-1'); + expect(profile1Ops).toHaveLength(2); + expect(profile1Ops.every(op => op.profileId === 'profile-1')).toBe(true); + }); + }); + + describe('selectBestProfile', () => { + it('should select profile with highest availability score', async () => { + const result = await coordinator.selectBestProfile(); + + // Profile 2 has higher availability score (90 vs 70) + expect(result).not.toBeNull(); + expect(result?.profileId).toBe('profile-2'); + }); + + it('should exclude specified profile', async () => { + const result = await coordinator.selectBestProfile('profile-2'); + + // Profile 2 is excluded, should select profile-1 + expect(result).not.toBeNull(); + expect(result?.profileId).toBe('profile-1'); + }); + + it('should consider active operations when scoring', async () => { + // Register multiple operations on profile-2 + coordinator.registerOperation('task-1', 'task', 'profile-2', 'Profile 2'); + coordinator.registerOperation('task-2', 'task', 'profile-2', 'Profile 2'); + + const result = await coordinator.selectBestProfile(); + + // Profile 2 has -30 penalty (2 ops * 15), score goes from 90 to 60 + // Profile 1 still at 70, should be selected + expect(result?.profileId).toBe('profile-1'); + }); + }); + + describe('handleRateLimit', () => { + it('should return new profile on rate limit', async () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + + const result = await coordinator.handleRateLimit('task-1', 'profile-1'); + + expect(result).not.toBeNull(); + expect(result?.profileId).toBe('profile-2'); + expect(result?.reason).toBe('reactive'); + }); + + it('should update operation with new profile', async () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + + await coordinator.handleRateLimit('task-1', 'profile-1'); + + const operation = coordinator.getOperation('task-1'); + expect(operation?.profileId).toBe('profile-2'); + }); + + it('should return null for unknown operation', async () => { + const result = await coordinator.handleRateLimit('non-existent', 'profile-1'); + expect(result).toBeNull(); + }); + + it('should emit queue-blocked event when no profiles available', async () => { + const blockedHandler = vi.fn(); + coordinator.on('queue-blocked', blockedHandler); + + // Register operation + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + + // Mock getAllProfilesUsage to return no available profiles + const { getUsageMonitor } = await import('../claude-profile/usage-monitor'); + (getUsageMonitor as ReturnType).mockReturnValue({ + getAllProfilesUsage: vi.fn(async () => ({ + allProfiles: [ + { + profileId: 'profile-1', + isAuthenticated: true, + isRateLimited: true, // Rate limited + availabilityScore: 0 + } + ] + })) + }); + + const result = await coordinator.handleRateLimit('task-1', 'profile-1'); + + expect(result).toBeNull(); + // Advance timers to flush notification batch + vi.advanceTimersByTime(2000); + expect(blockedHandler).toHaveBeenCalled(); + }); + }); + + describe('profile cooldown', () => { + it('should clear cooldown for profile', () => { + // Trigger a rate limit to create cooldown + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + coordinator.handleRateLimit('task-1', 'profile-1'); + + // Clear cooldown + coordinator.clearProfileCooldown('profile-1'); + + // Profile should be eligible again + const stats = coordinator.getStats(); + expect(stats.profilesInCooldown).toBe(0); + }); + }); + + describe('getStats', () => { + it('should return correct statistics', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + coordinator.registerOperation('task-2', 'roadmap', 'profile-1', 'Profile 1'); + coordinator.registerOperation('task-3', 'ideation', 'profile-2', 'Profile 2'); + + const stats = coordinator.getStats(); + + expect(stats.activeOperations).toBe(3); + expect(stats.operationsByType.task).toBe(1); + expect(stats.operationsByType.roadmap).toBe(1); + expect(stats.operationsByType.ideation).toBe(1); + }); + }); + + describe('cleanup', () => { + it('should clear all state on cleanup', () => { + coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1'); + + coordinator.cleanup(); + + expect(coordinator.getOperation('task-1')).toBeUndefined(); + expect(coordinator.getStats().activeOperations).toBe(0); + }); + }); +}); + +describe('getRecoveryCoordinator', () => { + beforeEach(() => { + SDKSessionRecoveryCoordinator.resetInstance(); + }); + + it('should return the singleton instance', () => { + const coordinator = getRecoveryCoordinator(); + const sameCoordinator = SDKSessionRecoveryCoordinator.getInstance(); + expect(coordinator).toBe(sameCoordinator); + }); +}); diff --git a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts new file mode 100644 index 00000000..64b8c04d --- /dev/null +++ b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts @@ -0,0 +1,537 @@ +/** + * SDK Session Recovery Coordinator + * + * Central coordinator for all SDK operations and rate limit recovery. + * Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination). + * + * Responsibilities: + * - Track all SDK operations (tasks, background operations) + * - Centralized rate limit handling + * - Profile selection with cooldown periods + * - Notification batching to prevent UI spam + */ + +import { EventEmitter } from 'events'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import { getUsageMonitor } from '../claude-profile/usage-monitor'; +import { safeSendToRenderer } from '../ipc-handlers/utils'; +import type { ProfileAssignmentReason } from '../../shared/types'; + +/** + * Types of SDK operations that can be registered + */ +export type SDKOperationType = 'task' | 'roadmap' | 'ideation' | 'changelog' | 'title-generation' | 'other'; + +/** + * Registered SDK operation + */ +export interface RegisteredOperation { + /** Unique operation ID */ + id: string; + /** Type of operation */ + type: SDKOperationType; + /** Associated profile ID */ + profileId: string; + /** Profile display name */ + profileName: string; + /** Captured session ID (if available) */ + sessionId?: string; + /** Operation start time */ + startedAt: Date; + /** Last activity timestamp */ + lastActivityAt: Date; + /** Custom metadata */ + metadata?: Record; +} + +/** + * Profile with cooldown tracking + */ +interface ProfileCooldown { + profileId: string; + rateLimitedAt: Date; + cooldownUntil: Date; + rateLimitCount: number; +} + +/** + * Configuration for the recovery coordinator + */ +export interface RecoveryCoordinatorConfig { + /** Cooldown period after rate limit (ms). Default: 60000 (1 minute) */ + cooldownPeriodMs: number; + /** Maximum consecutive rate limits before profile is marked unavailable. Default: 3 */ + maxConsecutiveRateLimits: number; + /** Notification batch window (ms). Default: 2000 */ + notificationBatchWindowMs: number; + /** Maximum notifications per batch. Default: 5 */ + maxNotificationsPerBatch: number; +} + +const DEFAULT_CONFIG: RecoveryCoordinatorConfig = { + cooldownPeriodMs: 60000, + maxConsecutiveRateLimits: 3, + notificationBatchWindowMs: 2000, + maxNotificationsPerBatch: 5, +}; + +/** + * Profile scoring constants + */ +const OPERATION_PENALTY_POINTS = 15; // Penalty per active operation on a profile +const RATE_LIMIT_PENALTY_POINTS = 5; // Penalty per previous rate limit + +/** + * Notification types for batching + */ +type NotificationType = 'profile-swap' | 'rate-limit' | 'blocked'; + +interface PendingNotification { + type: NotificationType; + data: unknown; + timestamp: Date; +} + +/** + * SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery + * + * This singleton coordinates all SDK operations across the application: + * - Tasks (via AgentManager) + * - Background operations (roadmap, ideation, changelog) + * - Title generation + * + * Provides unified rate limit handling and profile selection. + */ +export class SDKSessionRecoveryCoordinator extends EventEmitter { + private static instance: SDKSessionRecoveryCoordinator | null = null; + + private operations: Map = new Map(); + private profileCooldowns: Map = new Map(); + private config: RecoveryCoordinatorConfig; + private getMainWindow: (() => BrowserWindow | null) | null = null; + + // Notification batching + private pendingNotifications: PendingNotification[] = []; + private notificationBatchTimeout: NodeJS.Timeout | null = null; + + private constructor(config: Partial = {}) { + super(); + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get the singleton instance + */ + static getInstance(config?: Partial): SDKSessionRecoveryCoordinator { + if (!SDKSessionRecoveryCoordinator.instance) { + SDKSessionRecoveryCoordinator.instance = new SDKSessionRecoveryCoordinator(config); + } + return SDKSessionRecoveryCoordinator.instance; + } + + /** + * Reset the singleton (for testing) + */ + static resetInstance(): void { + if (SDKSessionRecoveryCoordinator.instance) { + SDKSessionRecoveryCoordinator.instance.cleanup(); + SDKSessionRecoveryCoordinator.instance = null; + } + } + + /** + * Set the main window getter for sending notifications + */ + setMainWindowGetter(getter: () => BrowserWindow | null): void { + this.getMainWindow = getter; + } + + /** + * Register a new SDK operation + */ + registerOperation( + id: string, + type: SDKOperationType, + profileId: string, + profileName: string, + metadata?: Record + ): RegisteredOperation { + const operation: RegisteredOperation = { + id, + type, + profileId, + profileName, + startedAt: new Date(), + lastActivityAt: new Date(), + metadata, + }; + + this.operations.set(id, operation); + console.log(`[RecoveryCoordinator] Registered operation: ${id} (${type}) on profile ${profileName}`); + + return operation; + } + + /** + * Update operation with session ID + */ + updateOperationSession(id: string, sessionId: string): void { + const operation = this.operations.get(id); + if (operation) { + operation.sessionId = sessionId; + operation.lastActivityAt = new Date(); + console.log(`[RecoveryCoordinator] Session captured for ${id}: ${sessionId.substring(0, 16)}...`); + } + } + + /** + * Update operation activity timestamp + */ + updateOperationActivity(id: string): void { + const operation = this.operations.get(id); + if (operation) { + operation.lastActivityAt = new Date(); + } + } + + /** + * Unregister an operation (completed or failed) + */ + unregisterOperation(id: string): void { + const operation = this.operations.get(id); + if (operation) { + this.operations.delete(id); + console.log(`[RecoveryCoordinator] Unregistered operation: ${id}`); + } + } + + /** + * Get an operation by ID + */ + getOperation(id: string): RegisteredOperation | undefined { + return this.operations.get(id); + } + + /** + * Get all operations of a specific type + */ + getOperationsByType(type: SDKOperationType): RegisteredOperation[] { + return Array.from(this.operations.values()).filter(op => op.type === type); + } + + /** + * Get all operations for a profile + */ + getOperationsByProfile(profileId: string): RegisteredOperation[] { + return Array.from(this.operations.values()).filter(op => op.profileId === profileId); + } + + /** + * Handle rate limit for an operation + * Returns the new profile to use, or null if no profile is available + */ + async handleRateLimit( + operationId: string, + rateLimitedProfileId: string + ): Promise<{ profileId: string; profileName: string; reason: ProfileAssignmentReason } | null> { + const operation = this.operations.get(operationId); + if (!operation) { + console.warn(`[RecoveryCoordinator] Unknown operation: ${operationId}`); + return null; + } + + // Record cooldown for rate-limited profile + this.recordProfileCooldown(rateLimitedProfileId); + + // Select best available profile + const newProfile = await this.selectBestProfile(rateLimitedProfileId); + + if (!newProfile) { + // No profiles available - queue is blocked + this.queueNotification('blocked', { + reason: 'All profiles are at capacity or in cooldown', + timestamp: new Date().toISOString(), + }); + + // Emit event for listeners + this.emit('queue-blocked', { reason: 'no_profiles_available', operationId }); + + return null; + } + + // Update operation with new profile + operation.profileId = newProfile.profileId; + operation.profileName = newProfile.profileName; + operation.lastActivityAt = new Date(); + + // Queue swap notification + this.queueNotification('profile-swap', { + operationId, + operationType: operation.type, + fromProfileId: rateLimitedProfileId, + fromProfileName: operation.profileName, + toProfileId: newProfile.profileId, + toProfileName: newProfile.profileName, + reason: 'rate_limit', + sessionId: operation.sessionId, + }); + + console.log( + `[RecoveryCoordinator] Rate limit recovery: ${operationId} swapped from ${rateLimitedProfileId} to ${newProfile.profileId}` + ); + + return { + profileId: newProfile.profileId, + profileName: newProfile.profileName, + reason: 'reactive', + }; + } + + /** + * Select the best available profile for a new operation + * Considers cooldowns, usage, and current load + */ + async selectBestProfile( + excludeProfileId?: string + ): Promise<{ profileId: string; profileName: string } | null> { + const profileManager = getClaudeProfileManager(); + const usageMonitor = getUsageMonitor(); + + // Get all profiles usage + const allProfilesUsage = await usageMonitor.getAllProfilesUsage(); + if (!allProfilesUsage) { + // Fallback to active profile (if not excluded) + const activeProfile = profileManager.getActiveProfile(); + if (excludeProfileId && activeProfile.id === excludeProfileId) { + return null; + } + return { profileId: activeProfile.id, profileName: activeProfile.name }; + } + + // Filter and score profiles + const now = new Date(); + const candidates: Array<{ + profileId: string; + profileName: string; + score: number; + }> = []; + + for (const profile of allProfilesUsage.allProfiles) { + // Skip excluded profile + if (excludeProfileId && profile.profileId === excludeProfileId) { + continue; + } + + // Skip unauthenticated profiles + if (!profile.isAuthenticated) { + continue; + } + + // Skip rate-limited profiles + if (profile.isRateLimited) { + continue; + } + + // Check cooldown + const cooldown = this.profileCooldowns.get(profile.profileId); + if (cooldown && cooldown.cooldownUntil > now) { + console.log( + `[RecoveryCoordinator] Profile ${profile.profileName} in cooldown until ${cooldown.cooldownUntil.toISOString()}` + ); + continue; + } + + // Check if profile has exceeded max consecutive rate limits + if (cooldown && cooldown.rateLimitCount >= this.config.maxConsecutiveRateLimits) { + console.log( + `[RecoveryCoordinator] Profile ${profile.profileName} exceeded max rate limits (${cooldown.rateLimitCount})` + ); + continue; + } + + // Count current operations on this profile + const operationsOnProfile = this.getOperationsByProfile(profile.profileId).length; + + // Calculate score: + // - Base: availability score (0-100) + // - Penalty: -15 per active operation + // - Penalty: -5 per previous rate limit + let score = profile.availabilityScore; + score -= operationsOnProfile * OPERATION_PENALTY_POINTS; + score -= (cooldown?.rateLimitCount ?? 0) * RATE_LIMIT_PENALTY_POINTS; + + candidates.push({ + profileId: profile.profileId, + profileName: profile.profileName, + score, + }); + } + + // Sort by score (highest first) + candidates.sort((a, b) => b.score - a.score); + + if (candidates.length === 0) { + return null; + } + + const best = candidates[0]; + console.log( + `[RecoveryCoordinator] Selected profile: ${best.profileName} (score: ${best.score})` + ); + + return { profileId: best.profileId, profileName: best.profileName }; + } + + /** + * Record a cooldown for a profile that hit rate limit + */ + private recordProfileCooldown(profileId: string): void { + const now = new Date(); + const existing = this.profileCooldowns.get(profileId); + + const cooldown: ProfileCooldown = { + profileId, + rateLimitedAt: now, + cooldownUntil: new Date(now.getTime() + this.config.cooldownPeriodMs), + rateLimitCount: (existing?.rateLimitCount ?? 0) + 1, + }; + + this.profileCooldowns.set(profileId, cooldown); + console.log( + `[RecoveryCoordinator] Profile ${profileId} in cooldown until ${cooldown.cooldownUntil.toISOString()} (count: ${cooldown.rateLimitCount})` + ); + } + + /** + * Clear cooldown for a profile (e.g., when usage resets) + */ + clearProfileCooldown(profileId: string): void { + this.profileCooldowns.delete(profileId); + console.log(`[RecoveryCoordinator] Cleared cooldown for profile ${profileId}`); + } + + /** + * Queue a notification for batched delivery + */ + private queueNotification(type: NotificationType, data: unknown): void { + this.pendingNotifications.push({ + type, + data, + timestamp: new Date(), + }); + + // Start batch timer if not already running + if (!this.notificationBatchTimeout) { + this.notificationBatchTimeout = setTimeout( + () => this.flushNotifications(), + this.config.notificationBatchWindowMs + ); + } + } + + /** + * Flush pending notifications to renderer + */ + private flushNotifications(): void { + this.notificationBatchTimeout = null; + + if (this.pendingNotifications.length === 0 || !this.getMainWindow) { + return; + } + + const mainWindow = this.getMainWindow(); + if (!mainWindow) { + return; + } + + // Group by type + const swaps = this.pendingNotifications.filter(n => n.type === 'profile-swap'); + const blocked = this.pendingNotifications.filter(n => n.type === 'blocked'); + + // Send profile swap notifications + if (swaps.length > 0) { + const toSend = swaps.slice(0, this.config.maxNotificationsPerBatch); + for (const notification of toSend) { + safeSendToRenderer( + this.getMainWindow, + IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, + notification.data + ); + } + if (swaps.length > this.config.maxNotificationsPerBatch) { + console.log( + `[RecoveryCoordinator] ${swaps.length - this.config.maxNotificationsPerBatch} swap notifications suppressed` + ); + } + } + + // Send blocked notification (only most recent) + if (blocked.length > 0) { + safeSendToRenderer( + this.getMainWindow, + IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, + blocked[blocked.length - 1].data + ); + } + + // Clear pending notifications + this.pendingNotifications = []; + } + + /** + * Get coordinator statistics + */ + getStats(): { + activeOperations: number; + operationsByType: Record; + profilesInCooldown: number; + pendingNotifications: number; + } { + const operationsByType: Record = { + task: 0, + roadmap: 0, + ideation: 0, + changelog: 0, + 'title-generation': 0, + other: 0, + }; + + for (const op of this.operations.values()) { + operationsByType[op.type]++; + } + + const now = new Date(); + const profilesInCooldown = Array.from(this.profileCooldowns.values()) + .filter(c => c.cooldownUntil > now).length; + + return { + activeOperations: this.operations.size, + operationsByType, + profilesInCooldown, + pendingNotifications: this.pendingNotifications.length, + }; + } + + /** + * Cleanup resources + */ + cleanup(): void { + if (this.notificationBatchTimeout) { + clearTimeout(this.notificationBatchTimeout); + this.notificationBatchTimeout = null; + } + this.operations.clear(); + this.profileCooldowns.clear(); + this.pendingNotifications = []; + this.removeAllListeners(); + } +} + +/** + * Get the global coordinator instance + */ +export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator { + return SDKSessionRecoveryCoordinator.getInstance(); +} diff --git a/apps/frontend/src/main/terminal-name-generator.ts b/apps/frontend/src/main/terminal-name-generator.ts index ca12cda8..1c39a4d0 100644 --- a/apps/frontend/src/main/terminal-name-generator.ts +++ b/apps/frontend/src/main/terminal-name-generator.ts @@ -8,7 +8,7 @@ import { app } from 'electron'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); import { EventEmitter } from 'events'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; +import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from './rate-limit-detector'; import { parsePythonCommand } from './python-detector'; import { pythonEnvManager } from './python-env-manager'; @@ -163,8 +163,17 @@ export class TerminalNameGenerator extends EventEmitter { hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN }); - // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) - const profileEnv = getProfileEnv(); + // Use centralized function that automatically handles rate limits and capacity + const profileResult = getBestAvailableProfileEnv(); + const profileEnv = profileResult.env; + + if (profileResult.wasSwapped) { + debug('Using alternative profile for terminal name generation:', { + originalProfile: profileResult.originalProfile?.name, + selectedProfile: profileResult.profileName, + reason: profileResult.swapReason + }); + } return new Promise((resolve) => { // Use the venv Python where claude_agent_sdk is installed diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 8128ea7a..cc5b3668 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -11,6 +11,7 @@ import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils'; +import { getUsageMonitor } from '../claude-profile/usage-monitor'; import { getEmailFromConfigDir } from '../claude-profile/profile-utils'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; @@ -784,6 +785,41 @@ export function handleOnboardingComplete( detectedAt: new Date().toISOString() } as OnboardingCompleteEvent); } + + // Trigger immediate usage fetch after successful re-authentication + // This gives the user immediate feedback that their account is working + if (profileId) { + try { + const usageMonitor = getUsageMonitor(); + if (usageMonitor) { + // Clear any auth failure status for this profile since they just re-authenticated + usageMonitor.clearAuthFailedProfile(profileId); + + console.warn('[ClaudeIntegration] Triggering immediate usage fetch after re-authentication:', profileId); + + // Switch to this profile if it's not already active, then fetch usage + const profileManager = getClaudeProfileManager(); + + // Also clear the migration flag if this profile was migrated to an isolated directory + // This prevents the auth failure modal from showing again on next startup + if (profileManager.isProfileMigrated(profileId)) { + profileManager.clearMigratedProfile(profileId); + console.warn('[ClaudeIntegration] Cleared migration flag for re-authenticated profile:', profileId); + } + const activeProfile = profileManager.getActiveProfile(); + if (activeProfile?.id !== profileId) { + profileManager.setActiveProfile(profileId); + } + + // Small delay to allow profile switch to settle, then trigger usage fetch + setTimeout(() => { + usageMonitor.checkNow(); + }, 500); + } + } catch (error) { + console.error('[ClaudeIntegration] Failed to trigger post-auth usage fetch:', error); + } + } } /** diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index 5b27cbab..e0cfb365 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -8,7 +8,7 @@ import { app } from 'electron'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); import { EventEmitter } from 'events'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; +import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from './rate-limit-detector'; import { parsePythonCommand, getValidatedPythonPath } from './python-detector'; import { getConfiguredPythonPath } from './python-env-manager'; import { getAPIProfileEnv } from './services/profile'; @@ -149,11 +149,32 @@ export class TitleGenerator extends EventEmitter { const isApiProfileActive = Object.keys(apiProfileEnv).length > 0; // Only get OAuth profile env if no API profile is active to avoid conflicts - const profileEnv = isApiProfileActive ? {} : getProfileEnv(); + let profileEnv: Record = {}; + if (!isApiProfileActive) { + // Use centralized function that automatically handles rate limits and capacity + const profileResult = getBestAvailableProfileEnv(); + profileEnv = profileResult.env; + + if (profileResult.wasSwapped) { + debug('Using alternative profile for title generation:', { + originalProfile: profileResult.originalProfile?.name, + selectedProfile: profileResult.profileName, + reason: profileResult.swapReason + }); + } + } // Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode) const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv); + // Debug: Log the final environment that will be used + // Note: profileEnv from getBestAvailableProfileEnv() already includes CLAUDE_CODE_OAUTH_TOKEN='' + // when CLAUDE_CONFIG_DIR is set, ensuring the subprocess uses the correct credentials + debug('Final subprocess environment:', { + profileEnvCLAUDE_CONFIG_DIR: profileEnv.CLAUDE_CONFIG_DIR, + profileEnvClearsOAuthToken: profileEnv.CLAUDE_CODE_OAUTH_TOKEN === '' + }); + return new Promise((resolve) => { // Parse Python command to handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath); @@ -162,7 +183,7 @@ export class TitleGenerator extends EventEmitter { env: { ...process.env, ...autoBuildEnv, - ...profileEnv, // Claude OAuth profile (only when no API profile active) + ...profileEnv, // Claude OAuth profile - includes CLAUDE_CONFIG_DIR and clears CLAUDE_CODE_OAUTH_TOKEN ...apiProfileEnv, // API profile (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.) ...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode PYTHONUNBUFFERED: '1', diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 8c78e4f0..a9cbafe3 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -14,6 +14,7 @@ import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; import { McpAPI, createMcpAPI } from './modules/mcp-api'; import { ProfileAPI, createProfileAPI } from './profile-api'; import { ScreenshotAPI, createScreenshotAPI } from './screenshot-api'; +import { QueueAPI, createQueueAPI } from './queue-api'; export interface ElectronAPI extends ProjectAPI, @@ -32,6 +33,8 @@ export interface ElectronAPI extends ProfileAPI, ScreenshotAPI { github: GitHubAPI; + /** Queue routing API for rate limit recovery */ + queue: QueueAPI; } export const createElectronAPI = (): ElectronAPI => ({ @@ -47,7 +50,8 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createMcpAPI(), ...createProfileAPI(), ...createScreenshotAPI(), - github: createGitHubAPI() + github: createGitHubAPI(), + queue: createQueueAPI() // Queue routing for rate limit recovery }); // Export individual API creators for potential use in tests or specialized contexts @@ -65,7 +69,8 @@ export { createDebugAPI, createClaudeCodeAPI, createMcpAPI, - createScreenshotAPI + createScreenshotAPI, + createQueueAPI }; export type { @@ -84,5 +89,6 @@ export type { DebugAPI, ClaudeCodeAPI, McpAPI, - ScreenshotAPI + ScreenshotAPI, + QueueAPI }; diff --git a/apps/frontend/src/preload/api/queue-api.ts b/apps/frontend/src/preload/api/queue-api.ts new file mode 100644 index 00000000..cf98c3e2 --- /dev/null +++ b/apps/frontend/src/preload/api/queue-api.ts @@ -0,0 +1,153 @@ +/** + * Queue Routing API + * + * Preload API for rate limit recovery queue routing. + * Exposes IPC methods to the renderer for profile-aware task distribution. + */ + +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + IPCResult, + RunningTasksByProfile, + ProfileAssignmentReason, + ProfileSwapRecord, +} from '../../shared/types'; + +/** + * Result of best profile selection for a task + */ +export interface BestProfileResult { + profileId: string; + profileName: string; + availabilityScore: number; + reason: ProfileAssignmentReason; + runningTaskCount: number; +} + +/** + * Options for getting the best profile for a task + */ +export interface GetBestProfileOptions { + /** Profile ID to exclude (e.g., one that just hit rate limit) */ + excludeProfileId?: string; + /** Maximum tasks per profile before load balancing (default: 2) */ + perProfileMaxTasks?: number; + /** Usage threshold (0-1) before considering profile "busy" (default: 0.85) */ + profileThreshold?: number; +} + +/** + * Profile swap notification event payload + */ +export interface QueueProfileSwapEvent { + taskId: string; + swap: ProfileSwapRecord; +} + +/** + * Session captured event payload + */ +export interface QueueSessionCapturedEvent { + taskId: string; + sessionId: string; + capturedAt: string; +} + +export interface QueueAPI { + // Queue Routing Operations + /** + * Get running tasks grouped by profile + * Used for queue routing decisions + */ + getRunningTasksByProfile: () => Promise>; + + /** + * Get the best available profile for a new task + * Considers availability scores, running task counts, and rate limit status + */ + getBestProfileForTask: (options?: GetBestProfileOptions) => Promise>; + + /** + * Assign a profile to a task + * Called when a task is started or when profile is swapped + */ + assignProfileToTask: ( + taskId: string, + profileId: string, + profileName: string, + reason: ProfileAssignmentReason + ) => Promise; + + /** + * Update session ID for a task + * Called when session ID is captured from agent stdout + */ + updateTaskSession: (taskId: string, sessionId: string) => Promise; + + /** + * Get session ID for a task + */ + getTaskSession: (taskId: string) => Promise>; + + // Queue Routing Event Listeners + /** + * Listen for profile swap events + * Fired when a task's profile is swapped due to rate limit or capacity + */ + onQueueProfileSwapped: (callback: (event: QueueProfileSwapEvent) => void) => () => void; + + /** + * Listen for session captured events + * Fired when a session ID is captured from a running task + */ + onQueueSessionCaptured: (callback: (event: QueueSessionCapturedEvent) => void) => () => void; + + /** + * Listen for queue blocked events + * Fired when no profiles are available to run queued tasks + */ + onQueueBlockedNoProfiles: (callback: (info: { reason: string; timestamp: string }) => void) => () => void; +} + +export const createQueueAPI = (): QueueAPI => ({ + // Queue Routing Operations + getRunningTasksByProfile: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE), + + getBestProfileForTask: (options?: GetBestProfileOptions): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, options), + + assignProfileToTask: ( + taskId: string, + profileId: string, + profileName: string, + reason: ProfileAssignmentReason + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, taskId, profileId, profileName, reason), + + updateTaskSession: (taskId: string, sessionId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION, taskId, sessionId), + + getTaskSession: (taskId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_TASK_SESSION, taskId), + + // Queue Routing Event Listeners + onQueueProfileSwapped: (callback: (event: QueueProfileSwapEvent) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: QueueProfileSwapEvent) => callback(data); + ipcRenderer.on(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, handler); + }, + + onQueueSessionCaptured: (callback: (event: QueueSessionCapturedEvent) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: QueueSessionCapturedEvent) => callback(data); + ipcRenderer.on(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, handler); + }, + + onQueueBlockedNoProfiles: (callback: (info: { reason: string; timestamp: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, info: { reason: string; timestamp: string }) => callback(info); + ipcRenderer.on(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, handler); + }, +}); diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 4374ef75..298ace44 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -120,7 +120,7 @@ export interface TerminalAPI { // Usage Monitoring (Proactive Account Switching) requestUsageUpdate: () => Promise>; - requestAllProfilesUsage: () => Promise>; + requestAllProfilesUsage: (forceRefresh?: boolean) => Promise>; 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> => ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST), - requestAllProfilesUsage: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST), + requestAllProfilesUsage: (forceRefresh?: boolean): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, forceRefresh ?? false), onUsageUpdated: ( callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx index 7efb1fb0..c208e234 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx @@ -33,7 +33,9 @@ vi.mock('react-i18next', () => ({ 'common:usage.authenticationAriaLabel': 'Authentication: {{provider}}', 'common:usage.profile': 'Profile', 'common:usage.id': 'ID', - 'common:usage.apiEndpoint': 'API Endpoint' + 'common:usage.apiEndpoint': 'API Endpoint', + 'common:usage.claudeCode': 'Claude Code', + 'common:usage.apiKey': 'API Key' }; // Handle interpolation (e.g., "Authentication: {{provider}}") if (params && Object.keys(params).length > 0) { @@ -134,17 +136,17 @@ describe('AuthStatusIndicator', () => { ); }); - it('should display Anthropic provider with Lock icon', () => { + it('should display Claude Code badge with Lock icon for OAuth', () => { render(); - expect(screen.getByText('Anthropic')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument(); + expect(screen.getByText('Claude Code')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /authentication: claude code/i })).toBeInTheDocument(); }); it('should have correct aria-label for OAuth', () => { render(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Claude Code'); }); }); @@ -155,17 +157,17 @@ describe('AuthStatusIndicator', () => { ); }); - it('should display provider label (Anthropic) with Key icon', () => { + it('should display API Key badge with Key icon for API profile', () => { render(); - expect(screen.getByText('Anthropic')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument(); + expect(screen.getByText('API Key')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /authentication: api key/i })).toBeInTheDocument(); }); it('should have correct aria-label for profile', () => { render(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key'); }); }); @@ -176,34 +178,34 @@ describe('AuthStatusIndicator', () => { ); }); - it('should fallback to Anthropic provider display', () => { + it('should fallback to OAuth (Claude Code) when profile not found', () => { render(); - expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getByText('Claude Code')).toBeInTheDocument(); }); }); describe('provider detection for different API profiles', () => { - it('should display z.ai provider label for z.ai profile', () => { + it('should display API Key badge for z.ai profile', () => { vi.mocked(useSettingsStore).mockReturnValue( createUseSettingsStoreMock({ activeProfileId: 'profile-3' }) ); render(); - expect(screen.getByText('z.ai')).toBeInTheDocument(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: z.ai'); + expect(screen.getByText('API Key')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key'); }); - it('should display ZHIPU AI provider label for ZHIPU profile', () => { + it('should display API Key badge for ZHIPU profile', () => { vi.mocked(useSettingsStore).mockReturnValue( createUseSettingsStoreMock({ activeProfileId: 'profile-4' }) ); render(); - expect(screen.getByText('ZHIPU AI')).toBeInTheDocument(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: ZHIPU AI'); + expect(screen.getByText('API Key')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key'); }); it('should apply correct color classes for each provider', () => { diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx index 5faf2c4c..502d139c 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx @@ -153,6 +153,8 @@ export function AuthStatusIndicator() { const Icon = isOAuth ? Lock : Key; // Compute once and reuse for aria-label and displayed text const localizedProviderLabel = getLocalizedProviderLabel(authStatus.provider); + // Badge label: "Claude Code" for OAuth, "API Key" for API profiles + const badgeLabel = isOAuth ? t('common:usage.claudeCode') : t('common:usage.apiKey'); return (
@@ -188,11 +190,11 @@ export function AuthStatusIndicator() { @@ -209,7 +211,7 @@ export function AuthStatusIndicator() { ? 'bg-orange-500/15 text-orange-500' : 'bg-primary/15 text-primary' }`}> - {isOAuth ? t('common:usage.oauth') : t('common:usage.apiProfile')} + {isOAuth ? t('common:usage.oauth') : t('common:usage.apiKey')}
@@ -222,6 +224,17 @@ export function AuthStatusIndicator() { {localizedProviderLabel} + {/* Claude Code subscription label for OAuth */} + {isOAuth && ( +
+
+ + {t('common:usage.subscription')} +
+ {t('common:usage.claudeCodeSubscription')} +
+ )} + {/* Profile details for API profiles */} {!isOAuth && ( <> diff --git a/apps/frontend/src/renderer/components/ProfileBadge.test.tsx b/apps/frontend/src/renderer/components/ProfileBadge.test.tsx new file mode 100644 index 00000000..d7c3a30c --- /dev/null +++ b/apps/frontend/src/renderer/components/ProfileBadge.test.tsx @@ -0,0 +1,199 @@ +/** + * @vitest-environment jsdom + */ + +/** + * Tests for ProfileBadge Component + * + * Tests the profile badge visual component used in task cards. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ProfileBadge, ProfileSwapIndicator } from './ProfileBadge'; +import { TooltipProvider } from './ui/tooltip'; + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'tasks:profileBadge.reason.proactive': 'Proactively assigned', + 'tasks:profileBadge.reason.reactive': 'Assigned after rate limit', + 'tasks:profileBadge.reason.manual': 'Manually assigned', + 'tasks:profileBadge.swapReason.capacity': 'capacity', + 'tasks:profileBadge.swapReason.rate_limit': 'rate limit', + 'tasks:profileBadge.swapReason.manual': 'manual', + 'tasks:profileBadge.swapReason.recovery': 'recovery' + }; + return translations[key] || key; + } + }) +})); + +// Wrapper with required providers +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +describe('ProfileBadge', () => { + describe('rendering', () => { + it('should render profile name', () => { + renderWithProviders(); + + expect(screen.getByText('Test Profile')).toBeInTheDocument(); + }); + + it('should truncate long profile names', () => { + renderWithProviders( + + ); + + // Name should be truncated to first 12 chars + ... + expect(screen.getByText('Very Long Pr...')).toBeInTheDocument(); + }); + }); + + describe('assignment reason styling', () => { + it('should show proactive badge style when running', () => { + renderWithProviders( + + ); + + // The text is inside a Badge (div) which has the color classes + const textElement = screen.getByText('Profile 1'); + // Get the Badge element by walking up the DOM - Badge > TooltipTrigger button > TextSpan + const badgeElement = textElement.closest('.bg-green-100'); + expect(badgeElement).toBeInTheDocument(); + }); + + it('should show reactive badge style when running', () => { + renderWithProviders( + + ); + + const textElement = screen.getByText('Profile 1'); + const badgeElement = textElement.closest('.bg-yellow-100'); + expect(badgeElement).toBeInTheDocument(); + }); + + it('should show manual badge style when running', () => { + renderWithProviders( + + ); + + const textElement = screen.getByText('Profile 1'); + const badgeElement = textElement.closest('.bg-blue-100'); + expect(badgeElement).toBeInTheDocument(); + }); + + it('should not show color when not running', () => { + renderWithProviders( + + ); + + const textElement = screen.getByText('Profile 1'); + // Should not have green styling when not running + const badgeElement = textElement.closest('.bg-green-100'); + expect(badgeElement).not.toBeInTheDocument(); + }); + }); + + describe('running state', () => { + it('should show running indicator when isRunning is true', () => { + renderWithProviders( + + ); + + const badge = screen.getByText('Profile 1'); + expect(badge.parentElement).toBeInTheDocument(); + }); + }); + + describe('compact mode', () => { + it('should render in compact mode with smaller sizing', () => { + renderWithProviders( + + ); + + // Check that the text is rendered - the component is displayed + expect(screen.getByText('Profile 1')).toBeInTheDocument(); + }); + + it('should render in normal mode with standard sizing', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Profile 1')).toBeInTheDocument(); + }); + }); + + describe('custom className', () => { + it('should apply custom className', () => { + renderWithProviders( + + ); + + // Check that the custom class is applied somewhere in the tree + expect(screen.getByText('Profile 1').closest('.custom-class')).toBeInTheDocument(); + }); + }); +}); + +describe('ProfileSwapIndicator', () => { + it('should render swap from and to profiles', () => { + render( + + ); + + expect(screen.getByText('Profile A')).toBeInTheDocument(); + expect(screen.getByText('Profile B')).toBeInTheDocument(); + }); + + it('should show strikethrough on from profile', () => { + render( + + ); + + const fromProfile = screen.getByText('Profile A'); + expect(fromProfile.className).toContain('line-through'); + }); + + it('should show reason text', () => { + render( + + ); + + expect(screen.getByText('(manual)')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/renderer/components/ProfileBadge.tsx b/apps/frontend/src/renderer/components/ProfileBadge.tsx new file mode 100644 index 00000000..bbc90d1f --- /dev/null +++ b/apps/frontend/src/renderer/components/ProfileBadge.tsx @@ -0,0 +1,147 @@ +/** + * ProfileBadge Component + * + * Displays the assigned profile for a task with visual indicators + * for the assignment reason (proactive, reactive, manual). + * + * Part of the intelligent rate limit recovery system. + */ + +import { User } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import type { ProfileAssignmentReason } from '@shared/types'; +import { useTranslation } from 'react-i18next'; + +interface ProfileBadgeProps { + /** Display name of the assigned profile */ + profileName: string; + /** Reason the profile was assigned */ + assignmentReason?: ProfileAssignmentReason; + /** Whether the task is currently running */ + isRunning?: boolean; + /** Compact mode for smaller displays */ + compact?: boolean; + /** Additional CSS classes */ + className?: string; +} + +/** + * Get badge variant based on assignment reason + */ +function getBadgeVariant(reason?: ProfileAssignmentReason, isRunning?: boolean): 'default' | 'secondary' | 'outline' | 'destructive' { + if (!isRunning) return 'secondary'; + + switch (reason) { + case 'proactive': + return 'default'; // Green - proactively assigned + case 'reactive': + return 'outline'; // Yellow/outline - assigned after rate limit + case 'manual': + return 'secondary'; // Blue - manually selected + default: + return 'secondary'; + } +} + +/** + * Get badge color class based on assignment reason + */ +function getBadgeColorClass(reason?: ProfileAssignmentReason, isRunning?: boolean): string { + if (!isRunning) return ''; + + switch (reason) { + case 'proactive': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 border-green-300 dark:border-green-700'; + case 'reactive': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700'; + case 'manual': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 border-blue-300 dark:border-blue-700'; + default: + return ''; + } +} + +/** + * ProfileBadge - Shows which Claude profile is assigned to a task + * + * Visual indicators: + * - Green: Proactively assigned (best available profile at task start) + * - Yellow: Reactively assigned (swapped after rate limit) + * - Blue: Manually assigned (user selected) + */ +export function ProfileBadge({ + profileName, + assignmentReason, + isRunning = false, + compact = false, + className = '' +}: ProfileBadgeProps) { + const { t } = useTranslation(['tasks']); + + // Truncate long profile names + const displayName = profileName.length > 15 + ? `${profileName.slice(0, 12)}...` + : profileName; + + const tooltipContent = ( +
+
{profileName}
+ {assignmentReason && ( +
+ {t(`tasks:profileBadge.reason.${assignmentReason}`)} +
+ )} +
+ ); + + const badge = ( + + + {displayName} + + ); + + return ( + + + {badge} + + + {tooltipContent} + + + ); +} + +/** + * ProfileSwapIndicator - Shows when a task's profile was swapped + * Used in task history to show profile swap events + */ +export function ProfileSwapIndicator({ + fromProfile, + toProfile, + reason +}: { + fromProfile: string; + toProfile: string; + reason: 'capacity' | 'rate_limit' | 'manual' | 'recovery'; +}) { + const { t } = useTranslation(['tasks']); + + return ( +
+ {fromProfile} + -> + {toProfile} + ({t(`tasks:profileBadge.swapReason.${reason}`)}) +
+ ); +} diff --git a/apps/frontend/src/renderer/components/UsageIndicator.tsx b/apps/frontend/src/renderer/components/UsageIndicator.tsx index eb64fe49..048beb52 100644 --- a/apps/frontend/src/renderer/components/UsageIndicator.tsx +++ b/apps/frontend/src/renderer/components/UsageIndicator.tsx @@ -7,7 +7,7 @@ */ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react'; +import { Activity, TrendingUp, AlertCircle, Clock, ChevronRight, Info, LogIn } from 'lucide-react'; import { Popover, PopoverContent, @@ -78,6 +78,7 @@ export function UsageIndicator() { const [otherProfiles, setOtherProfiles] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isAvailable, setIsAvailable] = useState(false); + const [activeProfileNeedsReauth, setActiveProfileNeedsReauth] = useState(false); const [isOpen, setIsOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); const hoverTimeoutRef = useRef(null); @@ -168,6 +169,7 @@ export function UsageIndicator() { isRateLimited: false, availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0), isActive: false, // It's no longer active + needsReauthentication: usage?.needsReauthentication, }; // 2. Convert target profile to a ClaudeUsageSnapshot for the active display @@ -180,6 +182,7 @@ export function UsageIndicator() { sessionResetTimestamp: targetProfile.sessionResetTimestamp, weeklyResetTimestamp: targetProfile.weeklyResetTimestamp, fetchedAt: new Date(), + needsReauthentication: targetProfile.needsReauthentication, }; // 3. Update the other profiles list: remove target, add current active @@ -199,6 +202,21 @@ export function UsageIndicator() { // Fetch fresh data in the background (will update via event listeners) window.electronAPI.requestUsageUpdate(); window.electronAPI.requestAllProfilesUsage?.(); + + // If the profile needs re-authentication, open Settings > Accounts + // so the user can complete the re-auth flow + if (targetProfile.needsReauthentication) { + // Close the popover first + setIsOpen(false); + setIsPinned(false); + // Open settings with accounts section after a short delay + setTimeout(() => { + const event = new CustomEvent('open-app-settings', { + detail: 'accounts' + }); + window.dispatchEvent(event); + }, 100); + } } else { // Revert to captured previous state console.error('[UsageIndicator] Failed to swap profile, reverting'); @@ -304,6 +322,9 @@ export function UsageIndicator() { // Filter out the active profile - we only want to show "other" profiles const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive); setOtherProfiles(nonActiveProfiles); + // Track if active profile needs re-auth + const activeProfile = allProfilesUsage.allProfiles.find(p => p.isActive); + setActiveProfileNeedsReauth(activeProfile?.needsReauthentication ?? false); }); // Request initial usage on mount @@ -326,6 +347,11 @@ export function UsageIndicator() { if (result.success && result.data) { const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive); setOtherProfiles(nonActiveProfiles); + // Track if active profile needs re-auth (even if main usage is unavailable) + const activeProfile = result.data.allProfiles.find(p => p.isActive); + if (activeProfile?.needsReauthentication) { + setActiveProfileNeedsReauth(true); + } } }).catch((error) => { console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error); @@ -347,23 +373,59 @@ export function UsageIndicator() { ); } - // Show unavailable state + // Show unavailable state - with better messaging based on cause if (!isAvailable || !usage) { + // Check if it's a re-auth issue (better UX than generic "not supported") + const needsReauth = activeProfileNeedsReauth; + return ( -
- - {t('common:usage.notAvailable')} -
+
-

{t('common:usage.dataUnavailable')}

-

- {t('common:usage.dataUnavailableDescription')} -

+ {needsReauth ? ( + <> +

{t('common:usage.reauthRequired')}

+

+ {t('common:usage.reauthRequiredDescription')} +

+ + + ) : ( + <> +

{t('common:usage.dataUnavailable')}

+

+ {t('common:usage.dataUnavailableDescription')} +

+ + )}
@@ -377,7 +439,10 @@ export function UsageIndicator() { const limitingPercent = Math.max(sessionPercent, weeklyPercent); // Badge color based on the limiting (higher) percentage - const badgeColorClasses = getBadgeColorClasses(limitingPercent); + // Override to red/destructive when re-auth is needed + const badgeColorClasses = usage.needsReauthentication + ? 'text-red-500 bg-red-500/10 border-red-500/20' + : getBadgeColorClasses(limitingPercent); // Individual colors for session and weekly in the badge const sessionColorClass = getColorClass(sessionPercent); @@ -395,7 +460,8 @@ export function UsageIndicator() { ); const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); - const Icon = + // Show AlertCircle when re-auth needed or high usage + const Icon = usage.needsReauthentication ? AlertCircle : maxUsage >= THRESHOLD_WARNING ? AlertCircle : maxUsage >= THRESHOLD_ELEVATED ? TrendingUp : Activity; @@ -411,16 +477,22 @@ export function UsageIndicator() { onClick={handleTriggerClick} > - {/* Dual usage display: Session | Weekly */} -
- - {Math.round(sessionPercent)} + {/* Show "!" when re-auth needed, otherwise dual usage display */} + {usage.needsReauthentication ? ( + + ! - - - {Math.round(weeklyPercent)} - -
+ ) : ( +
+ + {Math.round(sessionPercent)} + + + + {Math.round(weeklyPercent)} + +
+ )} {t('common:usage.usageBreakdown')} - {/* Session/5-hour usage */} -
-
- - - {sessionLabel} - - - {Math.round(usage.sessionPercent)}% - -
- {sessionResetTime && ( -
- - {sessionResetTime} + {/* Re-auth required prompt - shown when active profile needs re-authentication */} + {usage.needsReauthentication ? ( +
+
+ +
+

+ {t('common:usage.reauthRequired')} +

+

+ {t('common:usage.reauthRequiredDescription')} +

+
- )} -
-
-
-
+ + {t('common:usage.reauthButton')} +
- {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} - + ) : ( + <> + {/* Session/5-hour usage */} +
+
+ + + {sessionLabel} + + + {Math.round(usage.sessionPercent)}% + +
+ {sessionResetTime && ( +
+ + {sessionResetTime} +
+ )} +
+
+
+
+
+ {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} + +
+ )}
- )} -
- {/* Weekly/Monthly usage */} -
-
- - - {weeklyLabel} - - - {Math.round(usage.weeklyPercent)}% - -
- {weeklyResetTime && ( -
- - {weeklyResetTime} + {/* Weekly/Monthly usage */} +
+
+ + + {weeklyLabel} + + + {Math.round(usage.weeklyPercent)}% + +
+ {weeklyResetTime && ( +
+ + {weeklyResetTime} +
+ )} +
+
+
+
+
+ {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} + +
+ )}
- )} -
-
-
-
-
- {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} - -
- )} -
+ + )} {/* Active account footer - clickable to go to settings */}