feat: Multi-profile account swapping with token refresh and queue routing (#1496)

* refactor: streamline profile management and enhance usage monitoring

- Consolidated profile management logic to improve clarity and maintainability.
- Enhanced usage monitoring to support both OAuth and API profiles, ensuring accurate data retrieval.
- Updated error handling for API requests to provide better feedback and prevent silent failures.
- Improved test coverage for profile detection and usage monitoring functionalities.

These changes aim to optimize the user experience by ensuring that profile-related data is handled consistently and that usage metrics are accurately reported across different authentication methods.

* fix(tests): update tests for new auth badge display behavior

- Update AuthStatusIndicator tests to expect "Claude Code"/"API Key" badge labels
  instead of provider names (Anthropic, z.ai, ZHIPU AI)
- Fix usage-monitor tests that relied on console.warn calls (now uses debugLog)
- Add missing mock Response headers to prevent TypeError in fetch tests
- Convert dynamic require to static import in claude-profile-manager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Address PR review findings for account swapping

- Add `persistenceFailed` flag to EnsureValidTokenResult for callers
  to detect when token refresh succeeded but keychain write failed
- Add collision detection to profile migration to handle cases where
  two profile names sanitize to the same directory name
- Change hardcoded 'default' to 'unknown' in updateTaskSession when
  no profile assignment exists, and add optional profileInfo parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Resolve CodeQL security alerts

- Fix TOCTOU race condition in profile migration by using 'wx' flag
  for atomic file creation instead of existsSync check
- Remove unused imports: DEFAULT_CLAUDE_CONFIG_DIR, BrowserWindow,
  IPC_CHANNELS, User

The medium severity alerts for "Network data written to file" and
"File data in outbound network request" are expected behavior for
credential management and API authentication respectively.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(linux): Add Secret Service support for credential storage

Linux credentials now use the Secret Service API (gnome-keyring/kwallet)
via the `secret-tool` CLI, matching how macOS uses Keychain and Windows
uses Credential Manager.

Implementation:
- getCredentialsFromLinuxSecretService: Retrieve tokens from Secret Service
- getFullCredentialsFromLinuxSecretService: Retrieve full OAuth credentials
- updateLinuxSecretServiceCredentials: Store refreshed tokens
- Automatic fallback to .credentials.json file if Secret Service unavailable

The `secret-tool` command is part of libsecret-tools package, commonly
available on most Linux desktop distributions. This provides secure
credential storage instead of plaintext file storage.

Credentials are stored with:
- Label: "Claude Code-credentials"
- Attribute: application=claude-code (or claude-code-{hash} for profiles)

Fixes security gap where Linux used file storage while macOS and Windows
used proper secure credential stores.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: Add guidelines against providing time estimates in development

Introduced a critical section in the development guidelines emphasizing the avoidance of time estimates for tasks. This change highlights the misleading nature of traditional time predictions in AI-assisted development and encourages a focus on actionable steps and priority-based ordering instead. Examples of incorrect and correct approaches are provided for clarity.

* fix: address PR review findings for account swapping

MEDIUM severity fixes:
- Implement getBestProfileForTask handler with actual profile selection logic
- Refactor credential-utils.ts to eliminate code duplication with shared helpers
- Fix PowerShell escaping vulnerabilities with proper character escaping
- Use base64 encoding for JSON data passed to PowerShell scripts
- Add warning that returned token may be revoked after server-side refresh failure
- Document side effect in getBestAvailableProfileEnv function

LOW severity fixes:
- Reduce token fingerprint logging from 12+4 to 4+2 chars
- Document >= threshold as intentional (proactive switching before limits)
- Move writeFileSync import to top of file with other fs imports
- Remove unnecessary existsSync checks (mkdirSync recursive is idempotent)
- Add homedir to top-level imports from 'os'
- Add comment noting acceptable TOCTOU race window in profile-storage.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: clear credential cache after platform credential updates

Add clearCredentialCache() calls to all platform-specific update functions
to prevent stale cached tokens from being returned after successful updates:
- updateMacOSKeychainCredentials
- updateLinuxSecretServiceCredentials
- updateLinuxFileCredentials
- updateWindowsCredentialManagerCredentials

This ensures callers always get fresh credential values after token refresh,
rather than waiting for the 5-minute cache TTL to expire.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle persistenceFailed flag in token refresh calls

When token refresh succeeds but fails to persist to keychain, the user
needs to be notified to re-authenticate. Previously, the persistenceFailed
flag was ignored, which could lead to authentication errors on app restart.

Now all three call sites for ensureValidToken/reactiveTokenRefresh properly:
- Check the persistenceFailed flag
- Add the profile to needsReauthProfiles set when persistence fails
- Log a warning about the persistence failure
- Clear from needsReauthProfiles only when both refresh AND persistence succeed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: enhance credential management and re-authentication flow

- Updated macOS keychain handling to ensure existing credentials are deleted before adding new ones, preventing stale tokens.
- Integrated credential checks in profile authentication to verify token presence in the keychain, improving user experience by flagging profiles needing re-authentication.
- Enhanced the UsageIndicator component to provide clearer messaging for re-authentication requirements, improving user feedback.
- Added new localization strings for re-authentication prompts in both English and French.

This update addresses critical issues with credential persistence and user notifications, ensuring a smoother authentication experience across platforms.

* fix(auth): prevent false auth failures from file names in logs

Change auth failure pattern from \s* to \s+ to require at least one
whitespace character between "auth" and "failure/error/failed".

This fixes false positives where log lines like "[ParallelOrchestrator]
Reading AuthFailureModal.tsx" were incorrectly triggering auth failure
modals. The pattern matched "AuthFailure" (zero whitespace) even though
the OAuth token was valid.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(auth): use imported homedir instead of inline require

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-25 15:02:25 +01:00
committed by GitHub
parent ae4e48e8bf
commit 1e72c8d773
58 changed files with 6772 additions and 857 deletions
+27
View File
@@ -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.**
@@ -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;
@@ -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();
@@ -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<string, string[]>; 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);
}
}
@@ -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');
@@ -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<string, string>
): 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();
+7 -5
View File
@@ -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();
@@ -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();
});
});
});
+105
View File
@@ -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<number> = new Set();
private spawnCounter: number = 0;
// Queue routing state (rate limit recovery)
private taskProfileAssignments: Map<string, TaskProfileAssignment> = 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<string, string[]>; totalRunning: number } {
const byProfile: Record<string, string[]> = {};
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);
}
}
@@ -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<string, string> = {
@@ -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<string, string> = {
...augmentedEnv,
+102 -31
View File
@@ -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<string, string> = {};
// 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<string, string> {
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
@@ -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)', () => {
File diff suppressed because it is too large Load Diff
@@ -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);
@@ -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<string, unknown>): ProfileStore
}
if (data.version === STORE_VERSION) {
// Track profiles that were migrated in this session
const newlyMigratedProfileIds: string[] = [];
// Parse dates and migrate profile data
const profiles = data.profiles as ClaudeProfile[];
data.profiles = profiles.map((p: ClaudeProfile) => {
@@ -61,8 +160,23 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p;
// MIGRATION: Move profiles from shared ~/.claude to isolated directories
// This prevents interference with external Claude Code CLI usage
let configDir = profileWithoutToken.configDir;
if (usesLegacySharedDirectory(p)) {
configDir = migrateProfileToIsolatedDirectory(p);
// Track this profile as newly migrated (needs re-authentication)
newlyMigratedProfileIds.push(p.id);
console.warn('[ProfileStorage] Profile isolation migration:', {
profileName: p.name,
oldConfigDir: p.configDir,
newConfigDir: configDir
});
}
return {
...profileWithoutToken,
configDir, // Use migrated configDir
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
@@ -76,6 +190,14 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
}))
};
});
// Merge newly migrated profiles with any existing migratedProfileIds
const existingMigrated = (data.migratedProfileIds as string[] | undefined) || [];
const allMigratedIds = [...new Set([...existingMigrated, ...newlyMigratedProfileIds])];
if (allMigratedIds.length > 0) {
data.migratedProfileIds = allMigratedIds;
}
return data as unknown as ProfileStoreData;
}
@@ -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<string> {
// 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) {
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
});
@@ -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=<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<TokenRefreshResult> {
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<string, string> = {};
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<EnsureValidTokenResult> {
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<EnsureValidTokenResult> {
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 })
};
}
@@ -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();
});
});
File diff suppressed because it is too large Load Diff
+33 -3
View File
@@ -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) => {
+4 -2
View File
@@ -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<Record<string, string>> {
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();
@@ -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' });
@@ -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,
};
@@ -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<AgentManager>;
let mockWindow: Partial<BrowserWindow>;
let getMainWindow: () => BrowserWindow | null;
let registeredHandlers: Map<string, Function>;
let registeredEventListeners: Map<string, Function>;
beforeEach(() => {
vi.clearAllMocks();
registeredHandlers = new Map();
registeredEventListeners = new Map();
// Capture registered handlers
(ipcMain.handle as ReturnType<typeof vi.fn>).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();
});
});
});
@@ -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');
}
@@ -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;
@@ -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<IPCResult<AllProfilesUsage | null>> => {
async (_event: IpcMainInvokeEvent, forceRefresh: boolean = false): Promise<IPCResult<AllProfilesUsage | null>> => {
try {
const monitor = getUsageMonitor();
const allProfilesUsage = await monitor.getAllProfilesUsage();
const allProfilesUsage = await monitor.getAllProfilesUsage(forceRefresh);
return { success: true, data: allProfilesUsage };
} catch (error) {
return {
+1 -84
View File
@@ -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<string>();
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;
+184 -1
View File
@@ -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<string, string> {
return profileManager.getActiveProfileEnv();
}
/**
* Result of getting the best available profile environment
*/
export interface BestProfileEnvResult {
/** Environment variables for the selected profile */
env: Record<string, string>;
/** The profile ID that was selected */
profileId: string;
/** The profile name for logging/display */
profileName: string;
/** Whether a swap was performed (true if different from active profile) */
wasSwapped: boolean;
/** Reason for the swap if one occurred */
swapReason?: 'rate_limited' | 'at_capacity' | 'proactive';
/** The original active profile if a swap occurred */
originalProfile?: {
id: string;
name: string;
};
}
/**
* Get environment variables for the BEST available Claude profile and persist the profile swap.
*
* IMPORTANT: This function has the side effect of calling profileManager.setActiveProfile()
* when a better profile is found. This modifies global state and persists the profile swap.
*
* This is the preferred function for SDK operations that need profile environment.
* It automatically handles:
* 1. Checking if the active profile is explicitly rate-limited (received 429/rate limit error)
* 2. Checking if the active profile is at capacity (100% weekly usage)
* 3. Finding a better alternative profile if available
* 4. PERSISTING the swap by updating the active profile
*
* Use this instead of getProfileEnv() for any operation that will make Claude API calls.
*
* @returns Object containing env vars and metadata about which profile was selected
*/
export function getBestAvailableProfileEnv(): BestProfileEnvResult {
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
// Check for explicit rate limit (from previous API errors)
const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id);
// Check for capacity limit (100% weekly usage - will be rate limited on next request)
const isAtCapacity = activeProfile.usage?.weeklyUsagePercent !== undefined &&
activeProfile.usage.weeklyUsagePercent >= 100;
// Determine if we need to find an alternative
const needsSwap = rateLimitStatus.limited || isAtCapacity;
const swapReason: BestProfileEnvResult['swapReason'] = rateLimitStatus.limited
? 'rate_limited'
: isAtCapacity
? 'at_capacity'
: undefined;
if (needsSwap) {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] Active profile needs swap:', {
activeProfile: activeProfile.name,
isRateLimited: rateLimitStatus.limited,
isAtCapacity,
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
limitType: rateLimitStatus.type,
resetAt: rateLimitStatus.resetAt
});
}
// Try to find a better profile
const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id);
if (bestProfile) {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] Using alternative profile:', {
originalProfile: activeProfile.name,
alternativeProfile: bestProfile.name,
reason: swapReason
});
}
// Persist the swap by updating the active profile
// This ensures the UI reflects which account is actually being used
profileManager.setActiveProfile(bestProfile.id);
console.warn('[RateLimitDetector] Switched active profile:', {
from: activeProfile.name,
to: bestProfile.name,
reason: swapReason
});
// Trigger a usage refresh so the UI shows the new active profile
// This updates the UsageIndicator in the header
// We use fire-and-forget pattern to avoid making this function async
try {
const usageMonitor = getUsageMonitor();
// Force refresh all profiles usage data, which will emit 'all-profiles-usage-updated' event
// The UI components listen for this and will update automatically
usageMonitor.getAllProfilesUsage(true).then((allProfilesUsage) => {
if (allProfilesUsage) {
// Find the new active profile in allProfiles and emit its usage
// This ensures UsageIndicator.usage state also updates to show the new active account
const newActiveProfile = allProfilesUsage.allProfiles.find(p => p.isActive);
if (newActiveProfile) {
// Construct a ClaudeUsageSnapshot for the new active profile
const newActiveUsage = {
profileId: newActiveProfile.profileId,
profileName: newActiveProfile.profileName,
profileEmail: newActiveProfile.profileEmail,
sessionPercent: newActiveProfile.sessionPercent,
weeklyPercent: newActiveProfile.weeklyPercent,
sessionResetTimestamp: newActiveProfile.sessionResetTimestamp,
weeklyResetTimestamp: newActiveProfile.weeklyResetTimestamp,
fetchedAt: allProfilesUsage.fetchedAt,
needsReauthentication: newActiveProfile.needsReauthentication,
};
usageMonitor.emit('usage-updated', newActiveUsage);
}
// Also emit all-profiles-usage-updated for the other profiles list
usageMonitor.emit('all-profiles-usage-updated', allProfilesUsage);
}
}).catch((err) => {
console.warn('[RateLimitDetector] Failed to refresh usage after swap:', err);
});
} catch (err) {
// Usage monitor may not be initialized yet, that's OK
console.warn('[RateLimitDetector] Could not trigger usage refresh:', err);
}
const profileEnv = profileManager.getProfileEnv(bestProfile.id);
return {
env: ensureCleanProfileEnv(profileEnv),
profileId: bestProfile.id,
profileName: bestProfile.name,
wasSwapped: true,
swapReason,
originalProfile: {
id: activeProfile.id,
name: activeProfile.name
}
};
} else {
if (process.env.DEBUG === 'true') {
console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
}
}
}
// Use active profile (either it's fine, or no better alternative exists)
const activeEnv = profileManager.getActiveProfileEnv();
return {
env: ensureCleanProfileEnv(activeEnv),
profileId: activeProfile.id,
profileName: activeProfile.name,
wasSwapped: false
};
}
/**
* Ensure the profile environment is clean for subprocess invocation.
*
* When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent
* the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file)
* instead of reading fresh credentials from the specified config directory.
*
* This is critical for multi-account switching: when switching from a rate-limited
* account to an available one, the subprocess must use the new account's credentials.
*
* @param env - Profile environment from getProfileEnv() or getActiveProfileEnv()
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set
*/
function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
if (env.CLAUDE_CONFIG_DIR) {
// Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
return {
...env,
CLAUDE_CODE_OAUTH_TOKEN: ''
};
}
return env;
}
/**
* Get the active Claude profile ID
*/
@@ -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<typeof vi.fn>).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);
});
});
@@ -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<string, unknown>;
}
/**
* 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<string, RegisteredOperation> = new Map();
private profileCooldowns: Map<string, ProfileCooldown> = 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<RecoveryCoordinatorConfig> = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get the singleton instance
*/
static getInstance(config?: Partial<RecoveryCoordinatorConfig>): 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<string, unknown>
): 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<SDKOperationType, number>;
profilesInCooldown: number;
pendingNotifications: number;
} {
const operationsByType: Record<SDKOperationType, number> = {
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();
}
@@ -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
@@ -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);
}
}
}
/**
+24 -3
View File
@@ -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<string, string> = {};
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',
+9 -3
View File
@@ -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
};
+153
View File
@@ -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<IPCResult<RunningTasksByProfile>>;
/**
* Get the best available profile for a new task
* Considers availability scores, running task counts, and rate limit status
*/
getBestProfileForTask: (options?: GetBestProfileOptions) => Promise<IPCResult<BestProfileResult | null>>;
/**
* 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<IPCResult>;
/**
* Update session ID for a task
* Called when session ID is captured from agent stdout
*/
updateTaskSession: (taskId: string, sessionId: string) => Promise<IPCResult>;
/**
* Get session ID for a task
*/
getTaskSession: (taskId: string) => Promise<IPCResult<string | null>>;
// 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<IPCResult<RunningTasksByProfile>> =>
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE),
getBestProfileForTask: (options?: GetBestProfileOptions): Promise<IPCResult<BestProfileResult | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, options),
assignProfileToTask: (
taskId: string,
profileId: string,
profileName: string,
reason: ProfileAssignmentReason
): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, taskId, profileId, profileName, reason),
updateTaskSession: (taskId: string, sessionId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION, taskId, sessionId),
getTaskSession: (taskId: string): Promise<IPCResult<string | null>> =>
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);
},
});
@@ -120,7 +120,7 @@ export interface TerminalAPI {
// Usage Monitoring (Proactive Account Switching)
requestUsageUpdate: () => Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>>;
requestAllProfilesUsage: () => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void;
onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void;
onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void;
@@ -518,8 +518,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
requestUsageUpdate: (): Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST),
requestAllProfilesUsage: (): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST),
requestAllProfilesUsage: (forceRefresh?: boolean): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, forceRefresh ?? false),
onUsageUpdated: (
callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void
@@ -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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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(<AuthStatusIndicator />);
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', () => {
@@ -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 (
<div className="flex items-center gap-2">
@@ -188,11 +190,11 @@ export function AuthStatusIndicator() {
<button
type="button"
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${authStatus.badgeColor}`}
aria-label={t('common:usage.authenticationAriaLabel', { provider: localizedProviderLabel })}
aria-label={t('common:usage.authenticationAriaLabel', { provider: badgeLabel })}
>
<Icon className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">
{localizedProviderLabel}
{badgeLabel}
</span>
</button>
</TooltipTrigger>
@@ -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')}
</div>
</div>
@@ -222,6 +224,17 @@ export function AuthStatusIndicator() {
<span className="font-semibold text-xs">{localizedProviderLabel}</span>
</div>
{/* Claude Code subscription label for OAuth */}
{isOAuth && (
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lock className="h-3 w-3" />
<span className="text-[10px]">{t('common:usage.subscription')}</span>
</div>
<span className="font-medium text-[10px]">{t('common:usage.claudeCodeSubscription')}</span>
</div>
)}
{/* Profile details for API profiles */}
{!isOAuth && (
<>
@@ -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<string, string> = {
'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(<TooltipProvider>{ui}</TooltipProvider>);
}
describe('ProfileBadge', () => {
describe('rendering', () => {
it('should render profile name', () => {
renderWithProviders(<ProfileBadge profileName="Test Profile" />);
expect(screen.getByText('Test Profile')).toBeInTheDocument();
});
it('should truncate long profile names', () => {
renderWithProviders(
<ProfileBadge profileName="Very Long Profile Name Here" />
);
// 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(
<ProfileBadge
profileName="Profile 1"
assignmentReason="proactive"
isRunning={true}
/>
);
// 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(
<ProfileBadge
profileName="Profile 1"
assignmentReason="reactive"
isRunning={true}
/>
);
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(
<ProfileBadge
profileName="Profile 1"
assignmentReason="manual"
isRunning={true}
/>
);
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(
<ProfileBadge
profileName="Profile 1"
assignmentReason="proactive"
isRunning={false}
/>
);
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(
<ProfileBadge profileName="Profile 1" isRunning={true} />
);
const badge = screen.getByText('Profile 1');
expect(badge.parentElement).toBeInTheDocument();
});
});
describe('compact mode', () => {
it('should render in compact mode with smaller sizing', () => {
renderWithProviders(
<ProfileBadge profileName="Profile 1" compact={true} />
);
// 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(
<ProfileBadge profileName="Profile 1" compact={false} />
);
expect(screen.getByText('Profile 1')).toBeInTheDocument();
});
});
describe('custom className', () => {
it('should apply custom className', () => {
renderWithProviders(
<ProfileBadge profileName="Profile 1" className="custom-class" />
);
// 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(
<ProfileSwapIndicator
fromProfile="Profile A"
toProfile="Profile B"
reason="rate_limit"
/>
);
expect(screen.getByText('Profile A')).toBeInTheDocument();
expect(screen.getByText('Profile B')).toBeInTheDocument();
});
it('should show strikethrough on from profile', () => {
render(
<ProfileSwapIndicator
fromProfile="Profile A"
toProfile="Profile B"
reason="capacity"
/>
);
const fromProfile = screen.getByText('Profile A');
expect(fromProfile.className).toContain('line-through');
});
it('should show reason text', () => {
render(
<ProfileSwapIndicator
fromProfile="Profile A"
toProfile="Profile B"
reason="manual"
/>
);
expect(screen.getByText('(manual)')).toBeInTheDocument();
});
});
@@ -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 = (
<div className="text-sm">
<div className="font-medium">{profileName}</div>
{assignmentReason && (
<div className="text-muted-foreground">
{t(`tasks:profileBadge.reason.${assignmentReason}`)}
</div>
)}
</div>
);
const badge = (
<Badge
variant={getBadgeVariant(assignmentReason, isRunning)}
className={`
${getBadgeColorClass(assignmentReason, isRunning)}
${compact ? 'text-xs px-1.5 py-0.5' : 'text-xs px-2 py-0.5'}
${className}
`}
>
<User className={compact ? 'h-3 w-3 mr-0.5' : 'h-3 w-3 mr-1'} />
{displayName}
</Badge>
);
return (
<Tooltip>
<TooltipTrigger asChild>
{badge}
</TooltipTrigger>
<TooltipContent>
{tooltipContent}
</TooltipContent>
</Tooltip>
);
}
/**
* 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 (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="line-through">{fromProfile}</span>
<span>-&gt;</span>
<span className="font-medium">{toProfile}</span>
<span className="text-xs">({t(`tasks:profileBadge.swapReason.${reason}`)})</span>
</div>
);
}
@@ -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<ProfileUsageSummary[]>([]);
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<NodeJS.Timeout | null>(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<AppSection>('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 (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground cursor-help">
<Activity className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">{t('common:usage.notAvailable')}</span>
</div>
<button
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border cursor-help ${
needsReauth
? 'bg-red-500/10 border-red-500/20 text-red-500'
: 'bg-muted/50 text-muted-foreground'
}`}
aria-label={needsReauth ? t('common:usage.reauthRequired') : t('common:usage.dataUnavailable')}
>
{needsReauth ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">!</span>
</>
) : (
<>
<Activity className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">{t('common:usage.notAvailable')}</span>
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs w-64">
<div className="space-y-1">
<p className="font-medium">{t('common:usage.dataUnavailable')}</p>
<p className="text-muted-foreground text-[10px]">
{t('common:usage.dataUnavailableDescription')}
</p>
{needsReauth ? (
<>
<p className="font-medium text-red-500">{t('common:usage.reauthRequired')}</p>
<p className="text-muted-foreground text-[10px]">
{t('common:usage.reauthRequiredDescription')}
</p>
<button
onClick={handleOpenAccounts}
className="text-[10px] text-primary mt-1 font-medium underline hover:text-primary/80 cursor-pointer"
>
{t('common:usage.clickToOpenSettings')}
</button>
</>
) : (
<>
<p className="font-medium">{t('common:usage.dataUnavailable')}</p>
<p className="text-muted-foreground text-[10px]">
{t('common:usage.dataUnavailableDescription')}
</p>
</>
)}
</div>
</TooltipContent>
</Tooltip>
@@ -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}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
{/* Dual usage display: Session | Weekly */}
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
{Math.round(sessionPercent)}
{/* Show "!" when re-auth needed, otherwise dual usage display */}
{usage.needsReauthentication ? (
<span className="text-xs font-semibold text-red-500" title={t('common:usage.needsReauth')}>
!
</span>
<span className="text-muted-foreground/50"></span>
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
{Math.round(weeklyPercent)}
</span>
</div>
) : (
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
{Math.round(sessionPercent)}
</span>
<span className="text-muted-foreground/50"></span>
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
{Math.round(weeklyPercent)}
</span>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent
@@ -437,75 +509,102 @@ export function UsageIndicator() {
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
</div>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
{/* Re-auth required prompt - shown when active profile needs re-authentication */}
{usage.needsReauthentication ? (
<div className="py-2 space-y-3">
<div className="flex items-start gap-2.5 p-2.5 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-medium text-destructive">
{t('common:usage.reauthRequired')}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('common:usage.reauthRequiredDescription')}
</p>
</div>
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
<button
type="button"
onClick={handleOpenAccounts}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors text-xs font-medium"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
<LogIn className="h-3.5 w-3.5" />
{t('common:usage.reauthButton')}
</button>
</div>
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
) : (
<>
{/* Session/5-hour usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<Clock className="h-3 w-3" />
{sessionLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
{Math.round(usage.sessionPercent)}%
</span>
</div>
{sessionResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{sessionResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
</span>
</div>
)}
</div>
)}
</div>
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
{/* Weekly/Monthly usage */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{weeklyLabel}
</span>
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
{Math.round(usage.weeklyPercent)}%
</span>
</div>
{weeklyResetTime && (
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
{weeklyResetTime}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
)}
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
</div>
</div>
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{t('common:usage.used')}</span>
<span className="font-medium tabular-nums">
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
</span>
</div>
)}
</div>
</>
)}
{/* Active account footer - clickable to go to settings */}
<button
@@ -513,11 +612,21 @@ export function UsageIndicator() {
onClick={handleOpenAccounts}
className={`w-full pt-3 border-t flex items-center gap-2.5 hover:bg-muted/50 -mx-3 px-3 ${otherProfiles.length === 0 ? '-mb-3 pb-3 rounded-b-md' : 'pb-2'} transition-colors cursor-pointer group`}
>
{/* Initials Avatar */}
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-primary">
{getInitials(usage.profileName)}
</span>
{/* Initials Avatar with warning indicator for re-auth needed */}
<div className="relative">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
usage.needsReauthentication ? 'bg-red-500/10' : 'bg-primary/10'
}`}>
<span className={`text-xs font-semibold ${
usage.needsReauthentication ? 'text-red-500' : 'text-primary'
}`}>
{getInitials(usage.profileName)}
</span>
</div>
{/* Status dot for re-auth needed */}
{usage.needsReauthentication && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
</div>
{/* Account Info */}
@@ -526,8 +635,15 @@ export function UsageIndicator() {
<span className="text-[10px] text-muted-foreground font-medium">
{t('common:usage.activeAccount')}
</span>
{usage.needsReauthentication && (
<span className="text-[9px] px-1.5 py-0.5 bg-red-500/10 text-destructive rounded font-semibold">
{t('common:usage.needsReauth')}
</span>
)}
</div>
<div className="font-medium text-xs text-primary truncate">
<div className={`font-medium text-xs truncate ${
usage.needsReauthentication ? 'text-destructive' : 'text-primary'
}`}>
{usage.profileEmail || usage.profileName}
</div>
</div>
@@ -550,14 +666,14 @@ export function UsageIndicator() {
{/* Initials Avatar with status indicator */}
<div className="relative">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
profile.isRateLimited
profile.isRateLimited || profile.needsReauthentication
? 'bg-red-500/10'
: !profile.isAuthenticated
? 'bg-muted'
: 'bg-muted/80'
}`}>
<span className={`text-[10px] font-semibold ${
profile.isRateLimited
profile.isRateLimited || profile.needsReauthentication
? 'text-red-500'
: !profile.isAuthenticated
? 'text-muted-foreground'
@@ -567,7 +683,7 @@ export function UsageIndicator() {
</span>
</div>
{/* Status dot */}
{profile.isRateLimited && (
{(profile.isRateLimited || profile.needsReauthentication) && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
</div>
@@ -600,6 +716,10 @@ export function UsageIndicator() {
? t('common:usage.weeklyLimitReached')
: t('common:usage.sessionLimitReached')}
</span>
) : profile.needsReauthentication ? (
<span className="text-[9px] text-destructive">
{t('common:usage.needsReauth')}
</span>
) : !profile.isAuthenticated ? (
<span className="text-[9px] text-muted-foreground">
{t('common:usage.notAuthenticated')}
@@ -102,6 +102,8 @@ export interface UnifiedAccount {
isAuthenticated?: boolean;
/** Set when this account has identical usage to another - may indicate same underlying account */
isDuplicateUsage?: boolean;
/** Set when this account has an invalid refresh token and needs re-authentication */
needsReauthentication?: boolean;
}
interface SortableAccountItemProps {
@@ -281,6 +283,23 @@ function SortableAccountItem({ account, index }: SortableAccountItemProps) {
</TooltipContent>
</Tooltip>
)}
{/* Needs re-authentication warning - invalid refresh token */}
{account.type === 'oauth' && account.needsReauthentication && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 mt-1.5 cursor-help">
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-[10px] text-destructive">
{t('accounts.priority.needsReauth')}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
{t('accounts.priority.needsReauthHint')}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Right side badge for API profiles */}
@@ -28,7 +28,9 @@ import {
Activity,
AlertCircle,
Server,
Globe
Globe,
Clock,
TrendingUp
} from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -136,9 +138,10 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
const [profileUsageData, setProfileUsageData] = useState<Map<string, ProfileUsageSummary>>(new Map());
// Fetch all profiles usage data
const loadProfileUsageData = useCallback(async () => {
// Force refresh to get fresh data when Settings opens (bypasses 1-minute cache)
const loadProfileUsageData = useCallback(async (forceRefresh: boolean = false) => {
try {
const result = await window.electronAPI.requestAllProfilesUsage?.();
const result = await window.electronAPI.requestAllProfilesUsage?.(forceRefresh);
if (result?.success && result.data) {
const usageMap = new Map<string, ProfileUsageSummary>();
result.data.allProfiles.forEach(profile => {
@@ -174,6 +177,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
isRateLimited: usageData?.isRateLimited,
rateLimitType: usageData?.rateLimitType,
isAuthenticated: profile.isAuthenticated,
needsReauthentication: usageData?.needsReauthentication,
});
});
@@ -247,7 +251,9 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
loadClaudeProfiles();
loadAutoSwitchSettings();
loadPriorityOrder();
loadProfileUsageData();
// Force refresh usage data when Settings opens to get fresh data
// This bypasses the 1-minute cache to ensure accurate duplicate detection
loadProfileUsageData(true);
}
}, [isOpen, loadProfileUsageData]);
@@ -690,14 +696,21 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
</div>
) : (
<div className="space-y-2 mb-4">
{claudeProfiles.map((profile) => (
{claudeProfiles.map((profile) => {
// Get usage data to check needsReauthentication flag
const usageData = profileUsageData.get(profile.id);
const needsReauth = usageData?.needsReauthentication ?? false;
return (
<div
key={profile.id}
className={cn(
"rounded-lg border transition-colors",
profile.id === activeClaudeProfileId && !activeApiProfileId
? "border-primary bg-primary/5"
: "border-border bg-background"
needsReauth
? "border-destructive/50 bg-destructive/5"
: profile.id === activeClaudeProfileId && !activeApiProfileId
? "border-primary bg-primary/5"
: "border-border bg-background"
)}
>
<div className={cn(
@@ -756,7 +769,12 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
{t('accounts.claudeCode.active')}
</span>
)}
{profile.isAuthenticated ? (
{needsReauth ? (
<span className="text-xs bg-destructive/20 text-destructive px-1.5 py-0.5 rounded flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{t('accounts.priority.needsReauth')}
</span>
) : profile.isAuthenticated ? (
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
<Check className="h-3 w-3" />
{t('accounts.claudeCode.authenticated')}
@@ -770,6 +788,57 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
{profile.email && (
<span className="text-xs text-muted-foreground">{profile.email}</span>
)}
{/* Usage bars - show if we have usage data */}
{usageData && profile.isAuthenticated && !needsReauth && (
<div className="flex items-center gap-3 mt-1.5">
{/* Session usage */}
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
(usageData.sessionPercent ?? 0) >= 95 ? 'bg-red-500' :
(usageData.sessionPercent ?? 0) >= 91 ? 'bg-orange-500' :
(usageData.sessionPercent ?? 0) >= 71 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(usageData.sessionPercent ?? 0, 100)}%` }}
/>
</div>
<span className={`text-[10px] tabular-nums w-7 ${
(usageData.sessionPercent ?? 0) >= 95 ? 'text-red-500' :
(usageData.sessionPercent ?? 0) >= 91 ? 'text-orange-500' :
(usageData.sessionPercent ?? 0) >= 71 ? 'text-yellow-500' :
'text-muted-foreground'
}`}>
{Math.round(usageData.sessionPercent ?? 0)}%
</span>
</div>
{/* Weekly usage */}
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-muted-foreground" />
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
(usageData.weeklyPercent ?? 0) >= 95 ? 'bg-red-500' :
(usageData.weeklyPercent ?? 0) >= 91 ? 'bg-orange-500' :
(usageData.weeklyPercent ?? 0) >= 71 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(usageData.weeklyPercent ?? 0, 100)}%` }}
/>
</div>
<span className={`text-[10px] tabular-nums w-7 ${
(usageData.weeklyPercent ?? 0) >= 95 ? 'text-red-500' :
(usageData.weeklyPercent ?? 0) >= 91 ? 'text-orange-500' :
(usageData.weeklyPercent ?? 0) >= 71 ? 'text-yellow-500' :
'text-muted-foreground'
}`}>
{Math.round(usageData.weeklyPercent ?? 0)}%
</span>
</div>
</div>
)}
</>
)}
</div>
@@ -952,7 +1021,8 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
</div>
)}
</div>
))}
);
})}
</div>
)}
@@ -0,0 +1,330 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for Profile Swap Notifications Hook
*
* Tests notification batching, toast display, and event subscriptions.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useProfileSwapNotifications, useSessionCaptureListener } from './use-profile-swap-notifications';
import type { QueueProfileSwapEvent, QueueSessionCapturedEvent } from '../../preload/api/queue-api';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.defaultValue) return options.defaultValue;
return key;
}
})
}));
// Mock toast
const mockToast = vi.fn();
vi.mock('./use-toast', () => ({
toast: (props: unknown) => mockToast(props)
}));
// Setup mock electronAPI
const mockOnQueueProfileSwapped = vi.fn();
const mockOnQueueBlockedNoProfiles = vi.fn();
const mockOnQueueSessionCaptured = vi.fn();
describe('useProfileSwapNotifications', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Setup window.electronAPI mock
(window as unknown as { electronAPI: unknown }).electronAPI = {
queue: {
onQueueProfileSwapped: mockOnQueueProfileSwapped.mockReturnValue(() => {}),
onQueueBlockedNoProfiles: mockOnQueueBlockedNoProfiles.mockReturnValue(() => {}),
onQueueSessionCaptured: mockOnQueueSessionCaptured.mockReturnValue(() => {})
}
};
});
afterEach(() => {
vi.useRealTimers();
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
describe('subscription', () => {
it('should subscribe to profile swap events on mount', () => {
renderHook(() => useProfileSwapNotifications());
expect(mockOnQueueProfileSwapped).toHaveBeenCalledTimes(1);
expect(mockOnQueueBlockedNoProfiles).toHaveBeenCalledTimes(1);
});
it('should unsubscribe on unmount', () => {
const unsubSwap = vi.fn();
const unsubBlocked = vi.fn();
mockOnQueueProfileSwapped.mockReturnValue(unsubSwap);
mockOnQueueBlockedNoProfiles.mockReturnValue(unsubBlocked);
const { unmount } = renderHook(() => useProfileSwapNotifications());
unmount();
expect(unsubSwap).toHaveBeenCalled();
expect(unsubBlocked).toHaveBeenCalled();
});
it('should not subscribe when electronAPI is not available', () => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
renderHook(() => useProfileSwapNotifications());
expect(mockOnQueueProfileSwapped).not.toHaveBeenCalled();
});
});
describe('single swap notification', () => {
it('should show detailed notification for single swap', () => {
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
mockOnQueueProfileSwapped.mockImplementation((cb) => {
swapCallback = cb;
return () => {};
});
renderHook(() => useProfileSwapNotifications());
const swapEvent: QueueProfileSwapEvent = {
taskId: 'task-1',
swap: {
fromProfileId: 'profile-1',
fromProfileName: 'Profile 1',
toProfileId: 'profile-2',
toProfileName: 'Profile 2',
swappedAt: new Date().toISOString(),
reason: 'rate_limit',
sessionResumed: false
}
};
act(() => {
swapCallback?.(swapEvent);
});
// Advance timer to trigger batch processing
act(() => {
vi.advanceTimersByTime(2000);
});
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile Swapped',
duration: 5000
})
);
});
});
describe('batched notifications', () => {
it('should batch multiple swap events within window', () => {
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
mockOnQueueProfileSwapped.mockImplementation((cb) => {
swapCallback = cb;
return () => {};
});
renderHook(() => useProfileSwapNotifications());
const createSwapEvent = (taskId: string, toProfile: string): QueueProfileSwapEvent => ({
taskId,
swap: {
fromProfileId: 'profile-1',
fromProfileName: 'Profile 1',
toProfileId: toProfile,
toProfileName: `Profile ${toProfile}`,
swappedAt: new Date().toISOString(),
reason: 'capacity',
sessionResumed: false
}
});
// Trigger multiple swaps
act(() => {
swapCallback?.(createSwapEvent('task-1', 'p2'));
swapCallback?.(createSwapEvent('task-2', 'p2'));
swapCallback?.(createSwapEvent('task-3', 'p3'));
});
// Should not show toast yet
expect(mockToast).not.toHaveBeenCalled();
// Advance timer to trigger batch processing
act(() => {
vi.advanceTimersByTime(2000);
});
// Should show batch notification
expect(mockToast).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining('3 Profile Swaps')
})
);
});
it('should limit notifications to max per batch', () => {
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
mockOnQueueProfileSwapped.mockImplementation((cb) => {
swapCallback = cb;
return () => {};
});
renderHook(() => useProfileSwapNotifications());
const createSwapEvent = (taskId: string): QueueProfileSwapEvent => ({
taskId,
swap: {
fromProfileId: 'profile-1',
fromProfileName: 'Profile 1',
toProfileId: 'profile-2',
toProfileName: 'Profile 2',
swappedAt: new Date().toISOString(),
reason: 'rate_limit',
sessionResumed: false
}
});
// Trigger 7 swaps (more than MAX_NOTIFICATIONS_PER_BATCH = 5)
act(() => {
for (let i = 0; i < 7; i++) {
swapCallback?.(createSwapEvent(`task-${i}`));
}
});
act(() => {
vi.advanceTimersByTime(2000);
});
// Should only show one batched notification
expect(mockToast).toHaveBeenCalledTimes(1);
});
});
describe('queue blocked notification', () => {
it('should show destructive toast for queue blocked', () => {
let blockedCallback: ((info: { reason: string; timestamp: string }) => void) | undefined;
mockOnQueueBlockedNoProfiles.mockImplementation((cb) => {
blockedCallback = cb;
return () => {};
});
renderHook(() => useProfileSwapNotifications());
act(() => {
blockedCallback?.({
reason: 'all_rate_limited',
timestamp: new Date().toISOString()
});
});
act(() => {
vi.advanceTimersByTime(2000);
});
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Queue Blocked',
variant: 'destructive',
duration: 8000
})
);
});
});
describe('cleanup', () => {
it('should clear pending timeout on unmount', () => {
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
mockOnQueueProfileSwapped.mockImplementation((cb) => {
swapCallback = cb;
return () => {};
});
const { unmount } = renderHook(() => useProfileSwapNotifications());
// Trigger a swap to start the batch timeout
act(() => {
swapCallback?.({
taskId: 'task-1',
swap: {
fromProfileId: 'p1',
fromProfileName: 'Profile 1',
toProfileId: 'p2',
toProfileName: 'Profile 2',
swappedAt: new Date().toISOString(),
reason: 'rate_limit',
sessionResumed: false
}
});
});
// Unmount before timeout fires
unmount();
// Advance timer - should not cause errors or show toast
act(() => {
vi.advanceTimersByTime(2000);
});
expect(mockToast).not.toHaveBeenCalled();
});
});
});
describe('useSessionCaptureListener', () => {
beforeEach(() => {
vi.clearAllMocks();
(window as unknown as { electronAPI: unknown }).electronAPI = {
queue: {
onQueueSessionCaptured: mockOnQueueSessionCaptured.mockReturnValue(() => {})
}
};
});
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
it('should subscribe when callback provided', () => {
const callback = vi.fn();
renderHook(() => useSessionCaptureListener(callback));
expect(mockOnQueueSessionCaptured).toHaveBeenCalledWith(callback);
});
it('should not subscribe when callback is undefined', () => {
renderHook(() => useSessionCaptureListener(undefined));
expect(mockOnQueueSessionCaptured).not.toHaveBeenCalled();
});
it('should not subscribe when electronAPI is not available', () => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
const callback = vi.fn();
renderHook(() => useSessionCaptureListener(callback));
expect(mockOnQueueSessionCaptured).not.toHaveBeenCalled();
});
it('should unsubscribe on unmount', () => {
const unsubscribe = vi.fn();
mockOnQueueSessionCaptured.mockReturnValue(unsubscribe);
const callback = vi.fn();
const { unmount } = renderHook(() => useSessionCaptureListener(callback));
unmount();
expect(unsubscribe).toHaveBeenCalled();
});
});
@@ -0,0 +1,187 @@
/**
* Profile Swap Notifications Hook
*
* Listens for profile swap events from the queue routing system
* and displays toast notifications to inform the user.
*
* Part of the intelligent rate limit recovery system (Phase 7: Queue UX Enhancements).
*/
import { useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from './use-toast';
import type { QueueProfileSwapEvent, QueueSessionCapturedEvent } from '../../preload/api/queue-api';
/**
* Notification batching to prevent toast spam
* Batches notifications within a 2-second window
*/
interface NotificationQueue {
swaps: QueueProfileSwapEvent[];
blocked: { reason: string; timestamp: string }[];
timeoutId: NodeJS.Timeout | null;
}
const BATCH_WINDOW_MS = 2000;
const MAX_NOTIFICATIONS_PER_BATCH = 5;
/**
* Toast notification durations (milliseconds)
*/
const TOAST_DURATION_SWAP_MS = 5000; // Single swap or batch swap notification
const TOAST_DURATION_BLOCKED_MS = 8000; // Queue blocked notification (longer for critical alerts)
/**
* Hook to display toast notifications for profile swap events
*
* Automatically subscribes to:
* - Profile swap events (rate limit recovery)
* - Queue blocked events (no profiles available)
*
* Batches notifications to avoid toast spam when multiple events occur.
*/
export function useProfileSwapNotifications() {
const { t } = useTranslation(['tasks']);
const queueRef = useRef<NotificationQueue>({
swaps: [],
blocked: [],
timeoutId: null,
});
/**
* Process and display batched notifications
*/
const processBatch = useCallback(() => {
const queue = queueRef.current;
queue.timeoutId = null;
// Process swap notifications
if (queue.swaps.length > 0) {
const swapsToShow = queue.swaps.slice(0, MAX_NOTIFICATIONS_PER_BATCH);
const remainingSwaps = queue.swaps.length - swapsToShow.length;
if (swapsToShow.length === 1) {
// Single swap - show detailed notification
const swap = swapsToShow[0].swap;
toast({
title: t('tasks:queue.autoSwap.title', {
defaultValue: 'Profile Swapped',
}),
description: t('tasks:queue.autoSwap.description', {
from: swap.fromProfileName,
to: swap.toProfileName,
reason: t(`tasks:profileBadge.swapReason.${swap.reason}`),
defaultValue: `Switched from ${swap.fromProfileName} to ${swap.toProfileName} (${swap.reason})`,
}),
duration: TOAST_DURATION_SWAP_MS,
});
} else {
// Multiple swaps - show summary
const profileNames = [...new Set(swapsToShow.map(s => s.swap.toProfileName))];
toast({
title: t('tasks:queue.autoSwap.batchTitle', {
count: swapsToShow.length,
defaultValue: `${swapsToShow.length} Profile Swaps`,
}),
description: t('tasks:queue.autoSwap.batchDescription', {
profiles: profileNames.join(', '),
defaultValue: `Tasks redistributed to: ${profileNames.join(', ')}`,
}),
duration: TOAST_DURATION_SWAP_MS,
});
}
if (remainingSwaps > 0) {
console.log(`[ProfileSwapNotifications] ${remainingSwaps} additional swaps suppressed`);
}
queue.swaps = [];
}
// Process blocked notifications
if (queue.blocked.length > 0) {
toast({
title: t('tasks:queue.blocked.title', {
defaultValue: 'Queue Blocked',
}),
description: t('tasks:queue.blocked.description', {
defaultValue: 'All profiles are at capacity. Tasks will resume when a profile becomes available.',
}),
variant: 'destructive',
duration: TOAST_DURATION_BLOCKED_MS,
});
queue.blocked = [];
}
}, [t]);
/**
* Queue a notification for batched display
*/
const queueNotification = useCallback((
type: 'swap' | 'blocked',
data: QueueProfileSwapEvent | { reason: string; timestamp: string }
) => {
const queue = queueRef.current;
if (type === 'swap') {
queue.swaps.push(data as QueueProfileSwapEvent);
} else {
queue.blocked.push(data as { reason: string; timestamp: string });
}
// Start batch window if not already started
if (!queue.timeoutId) {
queue.timeoutId = setTimeout(processBatch, BATCH_WINDOW_MS);
}
}, [processBatch]);
useEffect(() => {
// Check if electronAPI and queue methods are available
if (!window.electronAPI?.queue) {
console.log('[ProfileSwapNotifications] Queue API not available');
return;
}
// Subscribe to profile swap events
const unsubscribeSwap = window.electronAPI.queue.onQueueProfileSwapped(
(event: QueueProfileSwapEvent) => {
console.log('[ProfileSwapNotifications] Profile swap event:', event);
queueNotification('swap', event);
}
);
// Subscribe to queue blocked events
const unsubscribeBlocked = window.electronAPI.queue.onQueueBlockedNoProfiles(
(info: { reason: string; timestamp: string }) => {
console.log('[ProfileSwapNotifications] Queue blocked event:', info);
queueNotification('blocked', info);
}
);
return () => {
unsubscribeSwap();
unsubscribeBlocked();
// Clear any pending batch timeout
if (queueRef.current.timeoutId) {
clearTimeout(queueRef.current.timeoutId);
}
};
}, [queueNotification]);
}
/**
* Hook to listen for session capture events (useful for debugging)
* This is separate from the main notification hook as it's primarily for internal use.
*/
export function useSessionCaptureListener(
onSessionCaptured?: (event: QueueSessionCapturedEvent) => void
) {
useEffect(() => {
if (!window.electronAPI?.queue || !onSessionCaptured) {
return;
}
const unsubscribe = window.electronAPI.queue.onQueueSessionCaptured(onSessionCaptured);
return unsubscribe;
}, [onSessionCaptured]);
}
@@ -237,6 +237,18 @@ const browserMockAPI: ElectronAPI = {
onAnalyzePreviewError: () => () => {}
},
// Queue Routing API (rate limit recovery)
queue: {
getRunningTasksByProfile: async () => ({ success: true, data: { byProfile: {}, totalRunning: 0 } }),
getBestProfileForTask: async () => ({ success: true, data: null }),
assignProfileToTask: async () => ({ success: true }),
updateTaskSession: async () => ({ success: true }),
getTaskSession: async () => ({ success: true, data: null }),
onQueueProfileSwapped: () => () => {},
onQueueSessionCaptured: () => () => {},
onQueueBlockedNoProfiles: () => () => {}
},
// Claude Code Operations
checkClaudeCodeVersion: async () => ({
success: true,
@@ -75,7 +75,7 @@ export const claudeProfileMock = {
data: null
}),
requestAllProfilesUsage: async () => ({
requestAllProfilesUsage: async (_forceRefresh?: boolean) => ({
success: true,
data: null
}),
+13 -1
View File
@@ -548,5 +548,17 @@ export const IPC_CHANNELS = {
// Screenshot capture
SCREENSHOT_GET_SOURCES: 'screenshot:getSources', // Get available screens/windows
SCREENSHOT_CAPTURE: 'screenshot:capture' // Capture screenshot from source
SCREENSHOT_CAPTURE: 'screenshot:capture', // Capture screenshot from source
// Queue routing (rate limit recovery)
QUEUE_GET_RUNNING_TASKS_BY_PROFILE: 'queue:getRunningTasksByProfile',
QUEUE_GET_BEST_PROFILE_FOR_TASK: 'queue:getBestProfileForTask',
QUEUE_ASSIGN_PROFILE_TO_TASK: 'queue:assignProfileToTask',
QUEUE_UPDATE_TASK_SESSION: 'queue:updateTaskSession',
QUEUE_GET_TASK_SESSION: 'queue:getTaskSession',
// Queue routing events (main -> renderer)
QUEUE_PROFILE_SWAPPED: 'queue:profileSwapped', // Task switched to different profile
QUEUE_SESSION_CAPTURED: 'queue:sessionCaptured', // Session ID captured from running task
QUEUE_BLOCKED_NO_PROFILES: 'queue:blockedNoProfiles' // All profiles unavailable
} as const;
@@ -418,7 +418,11 @@
"authenticationAriaLabel": "Authentication: {{provider}}",
"authenticationDetails": "Authentication Details",
"apiProfile": "API Profile",
"apiKey": "API Key",
"oauth": "OAuth",
"claudeCode": "Claude Code",
"claudeCodeSubscription": "Claude Code subscription",
"subscription": "Subscription",
"provider": "Provider",
"providerAnthropic": "Anthropic",
"providerZai": "z.ai",
@@ -447,6 +451,11 @@
"weeklyLimitReached": "Weekly limit reached",
"sessionLimitReached": "Session limit reached",
"notAuthenticated": "Not authenticated",
"needsReauth": "Needs re-auth",
"reauthRequired": "Re-authentication required",
"reauthRequiredDescription": "Your session has expired. Re-authenticate to view usage and continue using this account.",
"reauthButton": "Re-authenticate",
"clickToOpenSettings": "Click to open Settings →",
"sessionShort": "5-hour session usage",
"weeklyShort": "7-day weekly usage",
"swap": "Swap"
@@ -561,6 +561,8 @@
"needsAuth": "Not authenticated",
"duplicateUsage": "Duplicate usage detected",
"duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.",
"needsReauth": "Needs re-auth",
"needsReauthHint": "This profile's refresh token is invalid. Click to re-authenticate.",
"sessionUsage": "Session usage (5-hour window)",
"weeklyUsage": "Weekly usage (7-day window)",
"oauthSection": "Claude Accounts (cycle through first)",
@@ -418,7 +418,11 @@
"authenticationAriaLabel": "Authentification : {{provider}}",
"authenticationDetails": "Détails de l'authentification",
"apiProfile": "Profil API",
"apiKey": "Clé API",
"oauth": "OAuth",
"claudeCode": "Claude Code",
"claudeCodeSubscription": "Abonnement Claude Code",
"subscription": "Abonnement",
"provider": "Fournisseur",
"providerAnthropic": "Anthropic",
"providerZai": "z.ai",
@@ -447,6 +451,11 @@
"weeklyLimitReached": "Limite hebdomadaire atteinte",
"sessionLimitReached": "Limite de session atteinte",
"notAuthenticated": "Non authentifié",
"needsReauth": "Réauth requise",
"reauthRequired": "Ré-authentification requise",
"reauthRequiredDescription": "Votre session a expiré. Ré-authentifiez-vous pour voir l'utilisation et continuer à utiliser ce compte.",
"reauthButton": "Ré-authentifier",
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
"sessionShort": "Utilisation session 5 heures",
"weeklyShort": "Utilisation hebdomadaire 7 jours",
"swap": "Changer"
@@ -561,6 +561,8 @@
"needsAuth": "Non authentifié",
"duplicateUsage": "Doublon détecté",
"duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.",
"needsReauth": "Réauth requise",
"needsReauthHint": "Le token de rafraîchissement de ce profil est invalide. Cliquez pour vous réauthentifier.",
"sessionUsage": "Utilisation de session (fenêtre de 5 heures)",
"weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)",
"oauthSection": "Comptes Claude (utilisés en premier)",
+37
View File
@@ -77,6 +77,8 @@ export interface ClaudeUsageSnapshot {
weeklyUsageValue?: number;
/** Weekly usage limit (total quota) */
weeklyUsageLimit?: number;
/** True if profile has invalid refresh token and needs re-authentication */
needsReauthentication?: boolean;
}
/**
@@ -112,6 +114,8 @@ export interface ProfileUsageSummary {
lastFetchedAt?: string;
/** Error message if usage fetch failed */
fetchError?: string;
/** True if profile has invalid refresh token and needs re-authentication */
needsReauthentication?: boolean;
}
/**
@@ -240,3 +244,36 @@ export interface TerminalProfileChangedEvent {
sessionMigrated?: boolean;
}>;
}
// ============================================
// Queue Routing Types (Rate Limit Recovery)
// ============================================
/**
* Reason for profile assignment to a task
*/
export type ProfileAssignmentReason = 'proactive' | 'reactive' | 'manual';
/**
* Tracking of running tasks grouped by profile
*/
export interface RunningTasksByProfile {
/** Map of profileId → array of task IDs running on that profile */
byProfile: Record<string, string[]>;
/** Total number of running tasks across all profiles */
totalRunning: number;
}
/**
* Profile swap record for tracking history
*/
export interface ProfileSwapRecord {
fromProfileId: string;
fromProfileName: string;
toProfileId: string;
toProfileName: string;
swappedAt: string;
reason: 'capacity' | 'rate_limit' | 'manual' | 'recovery';
sessionId?: string;
sessionResumed: boolean;
}
+7 -2
View File
@@ -312,8 +312,10 @@ export interface ElectronAPI {
// Usage Monitoring (Proactive Account Switching)
/** Request current usage snapshot */
requestUsageUpdate: () => Promise<IPCResult<ClaudeUsageSnapshot | null>>;
/** Request all profiles usage immediately (for startup/refresh) */
requestAllProfilesUsage: () => Promise<IPCResult<AllProfilesUsage | null>>;
/** Request all profiles usage immediately (for startup/refresh)
* @param forceRefresh - If true, bypasses cache to get fresh data for all profiles
*/
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<AllProfilesUsage | null>>;
/** Listen for usage data updates */
onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void;
/** Listen for proactive swap notifications */
@@ -858,6 +860,9 @@ export interface ElectronAPI {
thumbnail: string;
}>>>;
capture: (options: { sourceId: string }) => Promise<IPCResult<string>>;
// Queue Routing API (rate limit recovery)
queue: import('../../preload/api/queue-api').QueueAPI;
}
declare global {
+58 -58
View File
@@ -755,7 +755,7 @@ class TestSchemaValidation:
"status": status,
}
subtask = Chunk.from_dict(subtask_data)
subtask = Subtask.from_dict(subtask_data)
assert subtask.status.value == status
def test_all_verification_types_valid(self):
@@ -859,8 +859,8 @@ class TestSchemaValidation:
"description": "Test subtask",
}
subtask = Chunk.from_dict(subtask_data)
assert subtask.status == ChunkStatus.PENDING
subtask = Subtask.from_dict(subtask_data)
assert subtask.status == SubtaskStatus.PENDING
# =========================================================================
# Invalid Schema Tests - Wrong Types
@@ -886,7 +886,7 @@ class TestSchemaValidation:
}
with pytest.raises(ValueError):
Chunk.from_dict(subtask_data)
Subtask.from_dict(subtask_data)
def test_invalid_phase_type_raises_error(self):
"""Invalid phase type raises ValueError."""
@@ -998,7 +998,7 @@ class TestSchemaValidation:
"critique_result": {"passed": True, "score": 9},
}
subtask = Chunk.from_dict(subtask_data)
subtask = Subtask.from_dict(subtask_data)
assert subtask.id == "complex-task"
assert subtask.service == "backend"
@@ -1050,10 +1050,10 @@ class TestSchemaValidation:
name="Phase One",
type=PhaseType.SETUP,
subtasks=[
Chunk(
Subtask(
id="task-1",
description="First task",
status=ChunkStatus.COMPLETED,
status=SubtaskStatus.COMPLETED,
service="backend",
files_to_modify=["file.py"],
verification=Verification(
@@ -1167,47 +1167,47 @@ class TestEdgeCaseStateTransitions:
def test_chunk_blocked_status_initialization(self):
"""Chunk can be initialized with blocked status."""
chunk = Chunk(
chunk = Subtask(
id="blocked-task",
description="Task waiting for investigation results",
status=ChunkStatus.BLOCKED,
status=SubtaskStatus.BLOCKED,
)
assert chunk.status == ChunkStatus.BLOCKED
assert chunk.status == SubtaskStatus.BLOCKED
assert chunk.started_at is None
assert chunk.completed_at is None
def test_chunk_blocked_to_pending_transition(self):
"""Blocked chunk can transition to pending (unblocking)."""
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
# Manually unblock by setting to pending
chunk.status = ChunkStatus.PENDING
chunk.status = SubtaskStatus.PENDING
assert chunk.status == ChunkStatus.PENDING
assert chunk.status == SubtaskStatus.PENDING
def test_chunk_blocked_to_in_progress_transition(self):
"""Blocked chunk can be started directly (auto-unblock)."""
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
chunk.start(session_id=1)
assert chunk.status == ChunkStatus.IN_PROGRESS
assert chunk.status == SubtaskStatus.IN_PROGRESS
assert chunk.started_at is not None
assert chunk.session_id == 1
def test_blocked_chunk_serialization_roundtrip(self):
"""Blocked status survives serialization/deserialization."""
chunk = Chunk(
chunk = Subtask(
id="blocked-task",
description="Blocked task",
status=ChunkStatus.BLOCKED,
status=SubtaskStatus.BLOCKED,
)
data = chunk.to_dict()
restored = Chunk.from_dict(data)
restored = Subtask.from_dict(data)
assert restored.status == ChunkStatus.BLOCKED
assert restored.status == SubtaskStatus.BLOCKED
assert data["status"] == "blocked"
def test_phase_with_all_blocked_chunks(self):
@@ -1216,8 +1216,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Blocked Phase",
subtasks=[
Chunk(id="c1", description="Task 1", status=ChunkStatus.BLOCKED),
Chunk(id="c2", description="Task 2", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Task 1", status=SubtaskStatus.BLOCKED),
Subtask(id="c2", description="Task 2", status=SubtaskStatus.BLOCKED),
],
)
@@ -1233,8 +1233,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Mixed Phase",
subtasks=[
Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED),
Chunk(id="c2", description="Task 2", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Task 1", status=SubtaskStatus.COMPLETED),
Subtask(id="c2", description="Task 2", status=SubtaskStatus.BLOCKED),
],
)
@@ -1251,7 +1251,7 @@ class TestEdgeCaseStateTransitions:
)
fix_phase = plan.phases[2] # Phase 3 - Fix
blocked_chunks = [c for c in fix_phase.subtasks if c.status == ChunkStatus.BLOCKED]
blocked_chunks = [c for c in fix_phase.subtasks if c.status == SubtaskStatus.BLOCKED]
assert len(blocked_chunks) == 2
assert any("fix" in c.id.lower() for c in blocked_chunks)
@@ -1270,7 +1270,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
],
),
],
@@ -1290,7 +1290,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Task 1", status=ChunkStatus.PENDING),
Subtask(id="c1", description="Task 1", status=SubtaskStatus.PENDING),
],
depends_on=[2], # Circular dependency
),
@@ -1298,7 +1298,7 @@ class TestEdgeCaseStateTransitions:
phase=2,
name="Phase 2",
subtasks=[
Chunk(id="c2", description="Task 2", status=ChunkStatus.PENDING),
Subtask(id="c2", description="Task 2", status=SubtaskStatus.PENDING),
],
depends_on=[1], # Circular dependency
),
@@ -1321,7 +1321,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Waiting Phase",
subtasks=[
Chunk(id="c1", description="Blocked task", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Blocked task", status=SubtaskStatus.BLOCKED),
],
),
],
@@ -1341,7 +1341,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Failed task", status=ChunkStatus.FAILED),
Subtask(id="c1", description="Failed task", status=SubtaskStatus.FAILED),
],
),
],
@@ -1364,10 +1364,10 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
Chunk(id="c2", description="Failed", status=ChunkStatus.FAILED),
Chunk(id="c3", description="Blocked", status=ChunkStatus.BLOCKED),
Chunk(id="c4", description="Pending", status=ChunkStatus.PENDING),
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
Subtask(id="c2", description="Failed", status=SubtaskStatus.FAILED),
Subtask(id="c3", description="Blocked", status=SubtaskStatus.BLOCKED),
Subtask(id="c4", description="Pending", status=SubtaskStatus.PENDING),
],
),
],
@@ -1414,7 +1414,7 @@ class TestEdgeCaseStateTransitions:
name="Real Work",
depends_on=[1],
subtasks=[
Chunk(id="c1", description="Actual task", status=ChunkStatus.PENDING),
Subtask(id="c1", description="Actual task", status=SubtaskStatus.PENDING),
],
),
],
@@ -1440,7 +1440,7 @@ class TestEdgeCaseStateTransitions:
name="Work Phase",
depends_on=[3],
subtasks=[
Chunk(id="c1", description="Task", status=ChunkStatus.PENDING),
Subtask(id="c1", description="Task", status=SubtaskStatus.PENDING),
],
),
],
@@ -1460,7 +1460,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Done Phase",
subtasks=[
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
],
),
Phase(
@@ -1468,7 +1468,7 @@ class TestEdgeCaseStateTransitions:
name="Work Phase",
depends_on=[1],
subtasks=[
Chunk(id="c2", description="Pending", status=ChunkStatus.PENDING),
Subtask(id="c2", description="Pending", status=SubtaskStatus.PENDING),
],
),
],
@@ -1487,31 +1487,31 @@ class TestEdgeCaseStateTransitions:
def test_blocked_unblocked_complete_transition(self):
"""Full transition from blocked -> pending -> in_progress -> completed."""
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
# Unblock
chunk.status = ChunkStatus.PENDING
assert chunk.status == ChunkStatus.PENDING
chunk.status = SubtaskStatus.PENDING
assert chunk.status == SubtaskStatus.PENDING
# Start
chunk.start(session_id=1)
assert chunk.status == ChunkStatus.IN_PROGRESS
assert chunk.status == SubtaskStatus.IN_PROGRESS
assert chunk.started_at is not None
# Complete
chunk.complete(output="Done successfully")
assert chunk.status == ChunkStatus.COMPLETED
assert chunk.status == SubtaskStatus.COMPLETED
assert chunk.completed_at is not None
assert chunk.actual_output == "Done successfully"
def test_blocked_to_failed_transition(self):
"""Blocked chunk can transition to failed without being started."""
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
# Mark as failed directly (e.g., investigation revealed it's not feasible)
chunk.fail(reason="Investigation revealed task is not feasible")
assert chunk.status == ChunkStatus.FAILED
assert chunk.status == SubtaskStatus.FAILED
assert "FAILED: Investigation revealed task is not feasible" in chunk.actual_output
def test_in_progress_subtask_blocks_phase_completion(self):
@@ -1520,8 +1520,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Active Phase",
subtasks=[
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
Chunk(id="c2", description="Working", status=ChunkStatus.IN_PROGRESS),
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
Subtask(id="c2", description="Working", status=SubtaskStatus.IN_PROGRESS),
],
)
@@ -1533,8 +1533,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Problematic Phase",
subtasks=[
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
Chunk(id="c2", description="Failed", status=ChunkStatus.FAILED),
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
Subtask(id="c2", description="Failed", status=SubtaskStatus.FAILED),
],
)
@@ -1550,7 +1550,7 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Blocked Phase",
subtasks=[
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
],
),
],
@@ -1560,7 +1560,7 @@ class TestEdgeCaseStateTransitions:
assert plan.get_next_subtask() is None
# Unblock the subtask
plan.phases[0].subtasks[0].status = ChunkStatus.PENDING
plan.phases[0].subtasks[0].status = SubtaskStatus.PENDING
# Now work is available
result = plan.get_next_subtask()
@@ -1570,21 +1570,21 @@ class TestEdgeCaseStateTransitions:
def test_failed_subtask_retry_transition(self):
"""Failed subtask can be reset to pending for retry."""
chunk = Chunk(id="test", description="Test", status=ChunkStatus.FAILED)
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.FAILED)
chunk.actual_output = "FAILED: Previous error"
# Reset for retry
chunk.status = ChunkStatus.PENDING
chunk.status = SubtaskStatus.PENDING
chunk.actual_output = None
chunk.started_at = None
chunk.completed_at = None
assert chunk.status == ChunkStatus.PENDING
assert chunk.status == SubtaskStatus.PENDING
assert chunk.actual_output is None
# Can be started again
chunk.start(session_id=2)
assert chunk.status == ChunkStatus.IN_PROGRESS
assert chunk.status == SubtaskStatus.IN_PROGRESS
assert chunk.session_id == 2
def test_plan_status_update_with_blocked_subtasks(self):
@@ -1596,8 +1596,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
Chunk(id="c2", description="Blocked", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
Subtask(id="c2", description="Blocked", status=SubtaskStatus.BLOCKED),
],
),
],
@@ -1618,8 +1618,8 @@ class TestEdgeCaseStateTransitions:
phase=1,
name="Phase 1",
subtasks=[
Chunk(id="c1", description="Blocked 1", status=ChunkStatus.BLOCKED),
Chunk(id="c2", description="Blocked 2", status=ChunkStatus.BLOCKED),
Subtask(id="c1", description="Blocked 1", status=SubtaskStatus.BLOCKED),
Subtask(id="c2", description="Blocked 2", status=SubtaskStatus.BLOCKED),
],
),
],