diff --git a/auto-claude-ui/src/main/agent/agent-manager.ts b/auto-claude-ui/src/main/agent/agent-manager.ts index 514edb9b..3974816f 100644 --- a/auto-claude-ui/src/main/agent/agent-manager.ts +++ b/auto-claude-ui/src/main/agent/agent-manager.ts @@ -23,6 +23,16 @@ export class AgentManager extends EventEmitter { private events: AgentEvents; private processManager: AgentProcessManager; private queueManager: AgentQueueManager; + private taskExecutionContext: Map = new Map(); constructor() { super(); @@ -32,6 +42,12 @@ export class AgentManager extends EventEmitter { this.events = new AgentEvents(); this.processManager = new AgentProcessManager(this.state, this.events, this); this.queueManager = new AgentQueueManager(this.state, this.events, this.processManager, this); + + // Listen for auto-swap restart events + this.on('auto-swap-restart-task', (taskId: string, newProfileId: string) => { + console.log('[AgentManager] Auto-swap restart:', taskId, newProfileId); + this.restartTask(taskId); + }); } /** @@ -82,6 +98,9 @@ export class AgentManager extends EventEmitter { args.push('--auto-approve'); } + // Store context for potential restart + this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata); + // Note: This is spec-creation but it chains to task-execution via run.py this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); } @@ -133,6 +152,9 @@ export class AgentManager extends EventEmitter { // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent // The options.parallel and options.workers are kept for future use or logging purposes + // Store context for potential restart + this.storeTaskContext(taskId, projectPath, specId, options, false); + console.log('[AgentManager] Spawning process with args:', args); this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); } @@ -232,4 +254,74 @@ export class AgentManager extends EventEmitter { getRunningTasks(): string[] { return this.state.getRunningTaskIds(); } + + /** + * Store task execution context for potential restarts + */ + private storeTaskContext( + taskId: string, + projectPath: string, + specId: string, + options: TaskExecutionOptions, + isSpecCreation?: boolean, + taskDescription?: string, + specDir?: string, + metadata?: SpecCreationMetadata + ): void { + this.taskExecutionContext.set(taskId, { + projectPath, + specId, + options, + isSpecCreation, + taskDescription, + specDir, + metadata, + swapCount: 0 + }); + } + + /** + * Restart task after profile swap + */ + restartTask(taskId: string): boolean { + const context = this.taskExecutionContext.get(taskId); + if (!context) { + console.error('[AgentManager] No context for task:', taskId); + return false; + } + + // Prevent infinite swap loops + if (context.swapCount >= 2) { + console.error('[AgentManager] Max swap count reached for task:', taskId); + return false; + } + + context.swapCount++; + console.log('[AgentManager] Restarting task:', taskId, 'swap count:', context.swapCount); + + // Kill current process + this.killTask(taskId); + + // Wait for cleanup, then restart + setTimeout(() => { + if (context.isSpecCreation) { + this.startSpecCreation( + taskId, + context.projectPath, + context.taskDescription!, + context.specDir, + context.metadata + ); + } else { + this.startTaskExecution( + taskId, + context.projectPath, + context.specId, + context.options + ); + } + }, 500); + + return true; + } } diff --git a/auto-claude-ui/src/main/agent/agent-process.ts b/auto-claude-ui/src/main/agent/agent-process.ts index b2579f2c..d885637c 100644 --- a/auto-claude-ui/src/main/agent/agent-process.ts +++ b/auto-claude-ui/src/main/agent/agent-process.ts @@ -8,6 +8,7 @@ import { AgentEvents } from './agent-events'; import { ProcessType, ExecutionProgressData } from './types'; import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; import { projectStore } from '../project-store'; +import { getClaudeProfileManager } from '../claude-profile-manager'; /** * Process spawning and lifecycle management @@ -280,10 +281,43 @@ export class AgentProcessManager { suggestedProfile: rateLimitDetection.suggestedProfile?.name }); - // Determine source type based on processType - const source = processType === 'spec-creation' ? 'task' : 'task'; + // Check if auto-swap is enabled + const profileManager = getClaudeProfileManager(); + const autoSwitchSettings = profileManager.getAutoSwitchSettings(); - // Emit rate limit event + if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit) { + console.log('[spawnProcess] Reactive auto-swap enabled'); + + const currentProfileId = rateLimitDetection.profileId; + const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); + + if (bestProfile) { + console.log('[spawnProcess] Reactive swap to:', bestProfile.name); + + // Switch active profile + profileManager.setActiveProfile(bestProfile.id); + + // Emit swap info (for modal) + const source = processType === 'spec-creation' ? 'task' : 'task'; + const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { + taskId + }); + rateLimitInfo.wasAutoSwapped = true; + rateLimitInfo.swappedToProfile = { + id: bestProfile.id, + name: bestProfile.name + }; + rateLimitInfo.swapReason = 'reactive'; + this.emitter.emit('sdk-rate-limit', rateLimitInfo); + + // Restart task + this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id); + return; + } + } + + // Fall back to manual modal (no auto-swap or no alternative profile) + const source = processType === 'spec-creation' ? 'task' : 'task'; const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { taskId }); diff --git a/auto-claude-ui/src/main/claude-profile/index.ts b/auto-claude-ui/src/main/claude-profile/index.ts index 4121af2d..3e391266 100644 --- a/auto-claude-ui/src/main/claude-profile/index.ts +++ b/auto-claude-ui/src/main/claude-profile/index.ts @@ -51,3 +51,6 @@ export { hasValidToken, expandHomePath } from './profile-utils'; + +// Usage monitoring (proactive account switching) +export { UsageMonitor, getUsageMonitor } from './usage-monitor'; diff --git a/auto-claude-ui/src/main/claude-profile/usage-monitor.ts b/auto-claude-ui/src/main/claude-profile/usage-monitor.ts new file mode 100644 index 00000000..491f558a --- /dev/null +++ b/auto-claude-ui/src/main/claude-profile/usage-monitor.ts @@ -0,0 +1,331 @@ +/** + * Usage Monitor - Proactive usage monitoring and account switching + * + * Monitors Claude account usage at configured intervals and automatically + * switches to alternative accounts before hitting rate limits. + * + * Uses hybrid approach: + * 1. Primary: Direct OAuth API (https://api.anthropic.com/api/oauth/usage) + * 2. Fallback: CLI /usage command parsing + */ + +import { EventEmitter } from 'events'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import { ClaudeUsageSnapshot } from '../../shared/types/agent'; + +export class UsageMonitor extends EventEmitter { + private static instance: UsageMonitor; + private intervalId: NodeJS.Timeout | null = null; + private currentUsage: ClaudeUsageSnapshot | null = null; + private isChecking = false; + private useApiMethod = true; // Try API first, fall back to CLI if it fails + + private constructor() { + super(); + console.log('[UsageMonitor] Initialized'); + } + + static getInstance(): UsageMonitor { + if (!UsageMonitor.instance) { + UsageMonitor.instance = new UsageMonitor(); + } + return UsageMonitor.instance; + } + + /** + * Start monitoring usage at configured interval + */ + start(): void { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + + if (!settings.enabled || !settings.proactiveSwapEnabled) { + console.log('[UsageMonitor] Proactive monitoring disabled'); + return; + } + + if (this.intervalId) { + console.log('[UsageMonitor] Already running'); + return; + } + + const interval = settings.usageCheckInterval || 30000; + console.log('[UsageMonitor] Starting with interval:', interval, 'ms'); + + // Check immediately + this.checkUsageAndSwap(); + + // Then check periodically + this.intervalId = setInterval(() => { + this.checkUsageAndSwap(); + }, interval); + } + + /** + * Stop monitoring + */ + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + console.log('[UsageMonitor] Stopped'); + } + } + + /** + * Get current usage snapshot (for UI indicator) + */ + getCurrentUsage(): ClaudeUsageSnapshot | null { + return this.currentUsage; + } + + /** + * Check usage and trigger swap if thresholds exceeded + */ + private async checkUsageAndSwap(): Promise { + if (this.isChecking) { + return; // Prevent concurrent checks + } + + this.isChecking = true; + + try { + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + + if (!activeProfile) { + console.log('[UsageMonitor] No active profile'); + return; + } + + // Fetch current usage (hybrid approach) + const usage = await this.fetchUsage(activeProfile.id, activeProfile.oauthToken); + if (!usage) { + console.log('[UsageMonitor] Failed to fetch usage'); + return; + } + + this.currentUsage = usage; + + // Emit usage update for UI + this.emit('usage-updated', usage); + + // Check thresholds + const settings = profileManager.getAutoSwitchSettings(); + const sessionExceeded = usage.sessionPercent >= settings.sessionThreshold; + const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold; + + if (sessionExceeded || weeklyExceeded) { + console.log('[UsageMonitor] Threshold exceeded:', { + sessionPercent: usage.sessionPercent, + sessionThreshold: settings.sessionThreshold, + weeklyPercent: usage.weeklyPercent, + weeklyThreshold: settings.weeklyThreshold + }); + + // Attempt proactive swap + await this.performProactiveSwap( + activeProfile.id, + sessionExceeded ? 'session' : 'weekly' + ); + } + } catch (error) { + console.error('[UsageMonitor] Check failed:', error); + } finally { + this.isChecking = false; + } + } + + /** + * Fetch usage - HYBRID APPROACH + * Tries API first, falls back to CLI if API fails + */ + private async fetchUsage( + profileId: string, + oauthToken?: string + ): Promise { + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (!profile) { + return null; + } + + // Attempt 1: Direct API call (preferred) + if (this.useApiMethod && oauthToken) { + const apiUsage = await this.fetchUsageViaAPI(oauthToken, profileId, profile.name); + if (apiUsage) { + console.log('[UsageMonitor] Successfully fetched via API'); + return apiUsage; + } + + // API failed - switch to CLI method for future calls + console.log('[UsageMonitor] API method failed, falling back to CLI'); + this.useApiMethod = false; + } + + // Attempt 2: CLI /usage command (fallback) + return await this.fetchUsageViaCLI(profileId, profile.name); + } + + /** + * Fetch usage via OAuth API endpoint + * Endpoint: https://api.anthropic.com/api/oauth/usage + */ + private async fetchUsageViaAPI( + oauthToken: string, + profileId: string, + profileName: string + ): Promise { + try { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${oauthToken}`, + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + } + }); + + if (!response.ok) { + console.error('[UsageMonitor] API error:', response.status, response.statusText); + return null; + } + + const data: any = await response.json(); + + // Expected response format: + // { + // "five_hour_utilization": 0.72, // 0.0-1.0 + // "seven_day_utilization": 0.45, // 0.0-1.0 + // "five_hour_reset_at": "2025-01-17T15:00:00Z", + // "seven_day_reset_at": "2025-01-20T12:00:00Z" + // } + + return { + sessionPercent: Math.round((data.five_hour_utilization || 0) * 100), + weeklyPercent: Math.round((data.seven_day_utilization || 0) * 100), + sessionResetTime: this.formatResetTime(data.five_hour_reset_at), + weeklyResetTime: this.formatResetTime(data.seven_day_reset_at), + profileId, + profileName, + fetchedAt: new Date(), + limitType: (data.seven_day_utilization || 0) > (data.five_hour_utilization || 0) + ? 'weekly' + : 'session' + }; + } catch (error) { + console.error('[UsageMonitor] API fetch failed:', error); + return null; + } + } + + /** + * Fetch usage via CLI /usage command (fallback) + */ + private async fetchUsageViaCLI( + profileId: string, + profileName: string + ): Promise { + const profileManager = getClaudeProfileManager(); + + // Use existing CLI-based usage fetching mechanism + const result = await profileManager.fetchProfileUsage(profileId); + + if (!result.success || !result.data) { + return null; + } + + return { + sessionPercent: result.data.sessionUsagePercent || 0, + weeklyPercent: result.data.weeklyUsagePercent || 0, + sessionResetTime: result.data.sessionResetTime, + weeklyResetTime: result.data.weeklyResetTime, + profileId, + profileName, + fetchedAt: new Date(), + limitType: (result.data.weeklyUsagePercent || 0) > (result.data.sessionUsagePercent || 0) + ? 'weekly' + : 'session' + }; + } + + /** + * Format ISO timestamp to human-readable reset time + */ + private formatResetTime(isoTimestamp?: string): string { + if (!isoTimestamp) return 'Unknown'; + + try { + const date = new Date(isoTimestamp); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffHours < 24) { + return `${diffHours}h ${diffMins}m`; + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + return `${diffDays}d ${remainingHours}h`; + } catch (error) { + return isoTimestamp; + } + } + + /** + * Perform proactive profile swap + */ + private async performProactiveSwap( + currentProfileId: string, + limitType: 'session' | 'weekly' + ): Promise { + const profileManager = getClaudeProfileManager(); + const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); + + if (!bestProfile) { + console.log('[UsageMonitor] No alternative profile for proactive swap'); + this.emit('proactive-swap-failed', { + reason: 'no_alternative', + currentProfile: currentProfileId + }); + return; + } + + console.log('[UsageMonitor] Proactive swap:', { + from: currentProfileId, + to: bestProfile.id, + reason: limitType + }); + + // Switch profile + profileManager.setActiveProfile(bestProfile.id); + + // Emit swap event + this.emit('proactive-swap-completed', { + fromProfile: { id: currentProfileId, name: profileManager.getProfile(currentProfileId)?.name }, + toProfile: { id: bestProfile.id, name: bestProfile.name }, + limitType, + timestamp: new Date() + }); + + // Notify UI + this.emit('show-swap-notification', { + fromProfile: profileManager.getProfile(currentProfileId)?.name, + toProfile: bestProfile.name, + reason: 'proactive', + limitType + }); + + // Fetch usage for newly active profile immediately + this.checkUsageAndSwap(); + } +} + +/** + * Get the singleton UsageMonitor instance + */ +export function getUsageMonitor(): UsageMonitor { + return UsageMonitor.getInstance(); +} diff --git a/auto-claude-ui/src/main/index.ts b/auto-claude-ui/src/main/index.ts index 03b27cfe..af596ff1 100644 --- a/auto-claude-ui/src/main/index.ts +++ b/auto-claude-ui/src/main/index.ts @@ -5,6 +5,8 @@ import { setupIpcHandlers } from './ipc-setup'; import { AgentManager } from './agent'; import { TerminalManager } from './terminal-manager'; import { pythonEnvManager } from './python-env-manager'; +import { getUsageMonitor } from './claude-profile/usage-monitor'; +import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; // Get icon path based on platform function getIconPath(): string { @@ -125,6 +127,17 @@ app.whenReady().then(() => { // Create window createWindow(); + // Initialize usage monitoring after window is created + if (mainWindow) { + // Setup event forwarding from usage monitor to renderer + initializeUsageMonitorForwarding(mainWindow); + + // Start the usage monitor + const usageMonitor = getUsageMonitor(); + usageMonitor.start(); + console.log('[main] Usage monitor initialized and started'); + } + // macOS: re-create window when dock icon is clicked app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -142,6 +155,11 @@ app.on('window-all-closed', () => { // Cleanup before quit app.on('before-quit', async () => { + // Stop usage monitor + const usageMonitor = getUsageMonitor(); + usageMonitor.stop(); + console.log('[main] Usage monitor stopped'); + // Kill all running agent processes if (agentManager) { await agentManager.killAll(); diff --git a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts index ed9350d1..11b5f8d9 100644 --- a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts @@ -316,6 +316,13 @@ export function registerTerminalHandlers( try { const profileManager = getClaudeProfileManager(); profileManager.updateAutoSwitchSettings(settings); + + // Restart usage monitor with new settings + const { getUsageMonitor } = await import('../claude-profile/usage-monitor'); + const monitor = getUsageMonitor(); + monitor.stop(); + monitor.start(); + return { success: true }; } catch (error) { return { @@ -408,6 +415,29 @@ export function registerTerminalHandlers( } ); + // ============================================ + // Usage Monitoring (Proactive Account Switching) + // ============================================ + + // Request current usage snapshot + ipcMain.handle( + IPC_CHANNELS.USAGE_REQUEST, + async (): Promise> => { + try { + const { getUsageMonitor } = await import('../claude-profile/usage-monitor'); + const monitor = getUsageMonitor(); + const usage = monitor.getCurrentUsage(); + return { success: true, data: usage }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get current usage' + }; + } + } + ); + + // Terminal session management (persistence/restore) ipcMain.handle( IPC_CHANNELS.TERMINAL_GET_SESSIONS, @@ -522,3 +552,24 @@ export function registerTerminalHandlers( } ); } + +/** + * Initialize usage monitor event forwarding to renderer process + * Call this after mainWindow is created + */ +export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): void { + const { getUsageMonitor } = require('../claude-profile/usage-monitor'); + const monitor = getUsageMonitor(); + + // Forward usage updates to renderer + monitor.on('usage-updated', (usage: import('../../shared/types').ClaudeUsageSnapshot) => { + mainWindow.webContents.send(IPC_CHANNELS.USAGE_UPDATED, usage); + }); + + // Forward proactive swap notifications to renderer + monitor.on('show-swap-notification', (notification: any) => { + mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification); + }); + + console.log('[terminal-handlers] Usage monitor event forwarding initialized'); +} diff --git a/auto-claude-ui/src/preload/api/terminal-api.ts b/auto-claude-ui/src/preload/api/terminal-api.ts index 5564e19e..260f9138 100644 --- a/auto-claude-ui/src/preload/api/terminal-api.ts +++ b/auto-claude-ui/src/preload/api/terminal-api.ts @@ -63,6 +63,11 @@ export interface TerminalAPI { getBestAvailableProfile: (excludeProfileId?: string) => Promise>; onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void; retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise; + + // Usage Monitoring (Proactive Account Switching) + requestUsageUpdate: () => Promise>; + onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void; + onProactiveSwapNotification: (callback: (notification: any) => void) => () => void; } export const createTerminalAPI = (): TerminalAPI => ({ @@ -267,5 +272,36 @@ export const createTerminalAPI = (): TerminalAPI => ({ }, retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request) + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request), + + // Usage Monitoring (Proactive Account Switching) + requestUsageUpdate: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST), + + onUsageUpdated: ( + callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + usage: import('../../shared/types').ClaudeUsageSnapshot + ): void => { + callback(usage); + }; + ipcRenderer.on(IPC_CHANNELS.USAGE_UPDATED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.USAGE_UPDATED, handler); + }; + }, + + onProactiveSwapNotification: ( + callback: (notification: any) => void + ): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, notification: any): void => { + callback(notification); + }; + ipcRenderer.on(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, handler); + }; + } }); diff --git a/auto-claude-ui/src/renderer/App.tsx b/auto-claude-ui/src/renderer/App.tsx index 14114e56..0a977f61 100644 --- a/auto-claude-ui/src/renderer/App.tsx +++ b/auto-claude-ui/src/renderer/App.tsx @@ -34,6 +34,8 @@ import { WelcomeScreen } from './components/WelcomeScreen'; import { RateLimitModal } from './components/RateLimitModal'; import { SDKRateLimitModal } from './components/SDKRateLimitModal'; import { OnboardingWizard } from './components/onboarding'; +import { UsageIndicator } from './components/UsageIndicator'; +import { ProactiveSwapListener } from './components/ProactiveSwapListener'; import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store'; import { useTaskStore, loadTasks } from './stores/task-store'; import { useSettingsStore, loadSettings } from './stores/settings-store'; @@ -113,12 +115,12 @@ export function App() { // Check if selected project needs initialization (e.g., .auto-claude folder was deleted) useEffect(() => { - if (selectedProject && !selectedProject.autoBuildPath && !showInitDialog && skippedInitProjectId !== selectedProject.id) { + if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) { // Project exists but isn't initialized - show init dialog setPendingProject(selectedProject); setShowInitDialog(true); } - }, [selectedProject, showInitDialog, skippedInitProjectId]); + }, [selectedProject, skippedInitProjectId]); // Load tasks when project changes useEffect(() => { @@ -250,6 +252,7 @@ export function App() { return ( +
{/* Sidebar */} {selectedProject && ( -
+
+ +
+
+
+ ); +} diff --git a/auto-claude-ui/src/renderer/components/SDKRateLimitModal.tsx b/auto-claude-ui/src/renderer/components/SDKRateLimitModal.tsx index e533568a..ac74f196 100644 --- a/auto-claude-ui/src/renderer/components/SDKRateLimitModal.tsx +++ b/auto-claude-ui/src/renderer/components/SDKRateLimitModal.tsx @@ -61,6 +61,12 @@ export function SDKRateLimitModal() { const [isRetrying, setIsRetrying] = useState(false); const [isAddingProfile, setIsAddingProfile] = useState(false); const [newProfileName, setNewProfileName] = useState(''); + const [swapInfo, setSwapInfo] = useState<{ + wasAutoSwapped: boolean; + swapReason?: 'proactive' | 'reactive'; + swappedFrom?: string; + swappedTo?: string; + } | null>(null); // Load profiles and auto-switch settings when modal opens useEffect(() => { @@ -72,8 +78,18 @@ export function SDKRateLimitModal() { if (sdkRateLimitInfo?.suggestedProfile?.id) { setSelectedProfileId(sdkRateLimitInfo.suggestedProfile.id); } + + // Set swap info if auto-swap occurred + if (sdkRateLimitInfo) { + setSwapInfo({ + wasAutoSwapped: sdkRateLimitInfo.wasAutoSwapped ?? false, + swapReason: sdkRateLimitInfo.swapReason, + swappedFrom: profiles.find(p => p.id === sdkRateLimitInfo.profileId)?.name, + swappedTo: sdkRateLimitInfo.swappedToProfile?.name + }); + } } - }, [isSDKModalOpen, sdkRateLimitInfo?.suggestedProfile?.id]); + }, [isSDKModalOpen, sdkRateLimitInfo, profiles]); // Reset selection when modal closes useEffect(() => { @@ -232,6 +248,47 @@ export function SDKRateLimitModal() {
+ {/* Swap notification info */} +
+ {swapInfo?.wasAutoSwapped ? ( + <> +

+ {swapInfo.swapReason === 'proactive' ? '✓ Proactive Swap' : '⚡ Reactive Swap'} +

+

+ {swapInfo.swapReason === 'proactive' + ? `Automatically switched from ${swapInfo.swappedFrom} to ${swapInfo.swappedTo} before hitting rate limit.` + : `Rate limit hit on ${swapInfo.swappedFrom}. Automatically switched to ${swapInfo.swappedTo} and restarted.` + } +

+

+ Your work continued without interruption. +

+ + ) : ( + <> +

Rate limit reached

+

+ The operation was stopped because {currentProfile?.name || 'your account'} reached its usage limit. + {hasMultipleProfiles + ? ' Switch to another account below to continue.' + : ' Add another Claude account to continue working.'} +

+ + )} +
+ + {/* Upgrade button */} + + {/* Reset time info */} {sdkRateLimitInfo.resetTime && (
diff --git a/auto-claude-ui/src/renderer/components/UsageIndicator.tsx b/auto-claude-ui/src/renderer/components/UsageIndicator.tsx new file mode 100644 index 00000000..38a3689c --- /dev/null +++ b/auto-claude-ui/src/renderer/components/UsageIndicator.tsx @@ -0,0 +1,140 @@ +/** + * Usage Indicator - Real-time Claude usage display in header + * + * Displays current session/weekly usage as a badge with color-coded status. + * Shows detailed breakdown on hover. + */ + +import React, { useState, useEffect } from 'react'; +import { Activity, TrendingUp, AlertCircle } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; +import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; + +export function UsageIndicator() { + const [usage, setUsage] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + // Listen for usage updates from main process + const unsubscribe = window.electronAPI.onUsageUpdated((snapshot: ClaudeUsageSnapshot) => { + setUsage(snapshot); + setIsVisible(true); + }); + + // Request initial usage on mount + window.electronAPI.requestUsageUpdate().then((result) => { + if (result.success && result.data) { + setUsage(result.data); + setIsVisible(true); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + if (!isVisible || !usage) { + return null; + } + + // Determine color based on highest usage percentage + const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); + + const colorClasses = + maxUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' : + maxUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' : + maxUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' : + 'text-green-500 bg-green-500/10 border-green-500/20'; + + const Icon = + maxUsage >= 91 ? AlertCircle : + maxUsage >= 71 ? TrendingUp : + Activity; + + return ( + + + + + + +
+ {/* Session usage */} +
+
+ Session Usage + {Math.round(usage.sessionPercent)}% +
+ {usage.sessionResetTime && ( +
+ Resets: {usage.sessionResetTime} +
+ )} + {/* Progress bar */} +
+
= 95 ? 'bg-red-500' : + usage.sessionPercent >= 91 ? 'bg-orange-500' : + usage.sessionPercent >= 71 ? 'bg-yellow-500' : + 'bg-green-500' + }`} + style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }} + /> +
+
+ +
+ + {/* Weekly usage */} +
+
+ Weekly Usage + {Math.round(usage.weeklyPercent)}% +
+ {usage.weeklyResetTime && ( +
+ Resets: {usage.weeklyResetTime} +
+ )} + {/* Progress bar */} +
+
= 99 ? 'bg-red-500' : + usage.weeklyPercent >= 91 ? 'bg-orange-500' : + usage.weeklyPercent >= 71 ? 'bg-yellow-500' : + 'bg-green-500' + }`} + style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }} + /> +
+
+ +
+ + {/* Active profile */} +
+ Active Account + {usage.profileName} +
+
+ + + + ); +} diff --git a/auto-claude-ui/src/renderer/components/settings/IntegrationSettings.tsx b/auto-claude-ui/src/renderer/components/settings/IntegrationSettings.tsx index 1df67ae9..74418074 100644 --- a/auto-claude-ui/src/renderer/components/settings/IntegrationSettings.tsx +++ b/auto-claude-ui/src/renderer/components/settings/IntegrationSettings.tsx @@ -14,15 +14,19 @@ import { Loader2, LogIn, ChevronDown, - ChevronRight + ChevronRight, + RefreshCw, + Activity, + AlertCircle } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; import { cn } from '../../lib/utils'; import { SettingsSection } from './SettingsSection'; import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; -import type { AppSettings, ClaudeProfile } from '../../../shared/types'; +import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings } from '../../../shared/types'; interface IntegrationSettingsProps { settings: AppSettings; @@ -53,10 +57,15 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte const [showManualToken, setShowManualToken] = useState(false); const [savingTokenProfileId, setSavingTokenProfileId] = useState(null); - // Load Claude profiles when section is shown + // Auto-swap settings state + const [autoSwitchSettings, setAutoSwitchSettings] = useState(null); + const [isLoadingAutoSwitch, setIsLoadingAutoSwitch] = useState(false); + + // Load Claude profiles and auto-swap settings when section is shown useEffect(() => { if (isOpen) { loadClaudeProfiles(); + loadAutoSwitchSettings(); } }, [isOpen]); @@ -256,6 +265,39 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte } }; + // Load auto-swap settings + const loadAutoSwitchSettings = async () => { + setIsLoadingAutoSwitch(true); + try { + const result = await window.electronAPI.getAutoSwitchSettings(); + if (result.success && result.data) { + setAutoSwitchSettings(result.data); + } + } catch (err) { + console.error('Failed to load auto-switch settings:', err); + } finally { + setIsLoadingAutoSwitch(false); + } + }; + + // Update auto-swap settings + const handleUpdateAutoSwitch = async (updates: Partial) => { + setIsLoadingAutoSwitch(true); + try { + const result = await window.electronAPI.updateAutoSwitchSettings(updates); + if (result.success) { + await loadAutoSwitchSettings(); + } else { + alert(`Failed to update settings: ${result.error || 'Please try again.'}`); + } + } catch (err) { + console.error('Failed to update auto-switch settings:', err); + alert('Failed to update settings. Please try again.'); + } finally { + setIsLoadingAutoSwitch(false); + } + }; + return (
+ {/* Auto-Switch Settings Section */} + {claudeProfiles.length > 1 && ( +
+
+ +

Automatic Account Switching

+
+ +
+

+ Automatically switch between Claude accounts to avoid interruptions. + Configure proactive monitoring to switch before hitting limits. +

+ + {/* Master toggle */} +
+
+ +

+ Master switch for all auto-swap features +

+
+ handleUpdateAutoSwitch({ enabled })} + disabled={isLoadingAutoSwitch} + /> +
+ + {autoSwitchSettings?.enabled && ( + <> + {/* Proactive Monitoring Section */} +
+
+
+ +

+ Check usage regularly and swap before hitting limits +

+
+ handleUpdateAutoSwitch({ proactiveSwapEnabled: value })} + disabled={isLoadingAutoSwitch} + /> +
+ + {autoSwitchSettings?.proactiveSwapEnabled && ( + <> + {/* Check interval */} +
+ + +
+ + {/* Session threshold */} +
+
+ + {autoSwitchSettings?.sessionThreshold ?? 95}% +
+ handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })} + disabled={isLoadingAutoSwitch} + className="w-full" + /> +

+ Switch when session usage reaches this level (recommended: 95%) +

+
+ + {/* Weekly threshold */} +
+
+ + {autoSwitchSettings?.weeklyThreshold ?? 99}% +
+ handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })} + disabled={isLoadingAutoSwitch} + className="w-full" + /> +

+ Switch when weekly usage reaches this level (recommended: 99%) +

+
+ + )} +
+ + {/* Reactive Recovery Section */} +
+
+
+ +

+ Auto-swap when unexpected rate limit is hit +

+
+ handleUpdateAutoSwitch({ autoSwitchOnRateLimit: value })} + disabled={isLoadingAutoSwitch} + /> +
+
+ + )} +
+
+ )} + {/* API Keys Section */}
diff --git a/auto-claude-ui/src/shared/constants/ipc.ts b/auto-claude-ui/src/shared/constants/ipc.ts index 7dfb1c03..5bca9e38 100644 --- a/auto-claude-ui/src/shared/constants/ipc.ts +++ b/auto-claude-ui/src/shared/constants/ipc.ts @@ -94,6 +94,11 @@ export const IPC_CHANNELS = { // Retry a rate-limited operation with a different profile CLAUDE_RETRY_WITH_PROFILE: 'claude:retryWithProfile', + // Usage monitoring (proactive account switching) + USAGE_UPDATED: 'claude:usageUpdated', // Event: usage data updated (main -> renderer) + USAGE_REQUEST: 'claude:usageRequest', // Request current usage snapshot + PROACTIVE_SWAP_NOTIFICATION: 'claude:proactiveSwapNotification', // Event: proactive swap occurred + // Settings SETTINGS_GET: 'settings:get', SETTINGS_SAVE: 'settings:save', diff --git a/auto-claude-ui/src/shared/types/agent.ts b/auto-claude-ui/src/shared/types/agent.ts index 183de4a3..a5344c03 100644 --- a/auto-claude-ui/src/shared/types/agent.ts +++ b/auto-claude-ui/src/shared/types/agent.ts @@ -24,6 +24,29 @@ export interface ClaudeUsageData { lastUpdated: Date; } +/** + * Real-time usage snapshot for proactive monitoring + * Returned from API or CLI usage check + */ +export interface ClaudeUsageSnapshot { + /** Session usage percentage (0-100) */ + sessionPercent: number; + /** Weekly usage percentage (0-100) */ + weeklyPercent: number; + /** When the session limit resets (human-readable or ISO) */ + sessionResetTime?: string; + /** When the weekly limit resets (human-readable or ISO) */ + weeklyResetTime?: string; + /** Profile ID this snapshot belongs to */ + profileId: string; + /** Profile name for display */ + profileName: string; + /** When this snapshot was captured */ + fetchedAt: Date; + /** Which limit is closest to threshold ('session' or 'weekly') */ + limitType?: 'session' | 'weekly'; +} + /** * Rate limit event recorded for a profile */ @@ -90,16 +113,24 @@ export interface ClaudeProfileSettings { * Settings for automatic profile switching */ export interface ClaudeAutoSwitchSettings { - /** Whether auto-switch is enabled */ + /** Master toggle - enables all auto-switch features */ enabled: boolean; - /** Session usage threshold (0-100) to trigger proactive switch consideration */ - sessionThreshold: number; - /** Weekly usage threshold (0-100) to trigger proactive switch consideration */ - weeklyThreshold: number; - /** Whether to automatically switch on rate limit (vs. prompting user) */ - autoSwitchOnRateLimit: boolean; - /** Interval (ms) to check usage via /usage command (0 = disabled) */ + + // Proactive monitoring settings + /** Enable proactive monitoring and swapping before hitting limits */ + proactiveSwapEnabled: boolean; + /** Interval (ms) to check usage (default: 30000 = 30s, 0 = disabled) */ usageCheckInterval: number; + + // Threshold settings + /** Session usage threshold (0-100) to trigger proactive switch (default: 95) */ + sessionThreshold: number; + /** Weekly usage threshold (0-100) to trigger proactive switch (default: 99) */ + weeklyThreshold: number; + + // Reactive recovery + /** Whether to automatically switch on unexpected rate limit (vs. prompting user) */ + autoSwitchOnRateLimit: boolean; } export interface ClaudeAuthResult { diff --git a/auto-claude-ui/src/shared/types/terminal.ts b/auto-claude-ui/src/shared/types/terminal.ts index ec9da62a..6fb21d66 100644 --- a/auto-claude-ui/src/shared/types/terminal.ts +++ b/auto-claude-ui/src/shared/types/terminal.ts @@ -106,6 +106,17 @@ export interface SDKRateLimitInfo { detectedAt: Date; /** Original error message */ originalError?: string; + + // Auto-swap information (NEW) + /** Whether this rate limit was automatically handled via account swap */ + wasAutoSwapped?: boolean; + /** Profile that was swapped to (if auto-swapped) */ + swappedToProfile?: { + id: string; + name: string; + }; + /** Why the swap occurred: 'proactive' (before limit) or 'reactive' (after limit hit) */ + swapReason?: 'proactive' | 'reactive'; } /** diff --git a/auto-claude/cli/workspace_commands.py b/auto-claude/cli/workspace_commands.py index 5bd2bfb5..ac6b150e 100644 --- a/auto-claude/cli/workspace_commands.py +++ b/auto-claude/cli/workspace_commands.py @@ -13,6 +13,7 @@ _PARENT_DIR = Path(__file__).parent.parent if str(_PARENT_DIR) not in sys.path: sys.path.insert(0, str(_PARENT_DIR)) +from core.workspace.git_utils import _is_auto_claude_file, is_lock_file from debug import debug_warning from ui import ( Icons, @@ -26,8 +27,6 @@ from workspace import ( review_existing_build, ) -from core.workspace.git_utils import is_lock_file - from .utils import print_banner # Import debug utilities @@ -266,14 +265,15 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: ) if match: file_path = match.group(1).strip() - if file_path and file_path not in result["conflicting_files"]: + # Skip .auto-claude files - they should never be merged + if file_path and file_path not in result["conflicting_files"] and not _is_auto_claude_file(file_path): result["conflicting_files"].append(file_path) # Fallback: if we didn't parse conflicts, use diff to find files changed in both branches if not result["conflicting_files"]: # Files changed in main since merge-base main_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, main_commit], + ["git", "diff", "--name-only", merge_base, result["base_branch"]], cwd=project_dir, capture_output=True, text=True, @@ -286,7 +286,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: # Files changed in spec branch since merge-base spec_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, spec_commit], + ["git", "diff", "--name-only", merge_base, spec_branch], cwd=project_dir, capture_output=True, text=True, @@ -298,8 +298,9 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: ) # Files modified in both = potential conflicts + # Filter out .auto-claude files - they should never be merged conflicting = main_files & spec_files - result["conflicting_files"] = list(conflicting) + result["conflicting_files"] = [f for f in conflicting if not _is_auto_claude_file(f)] debug( MODULE, f"Found {len(conflicting)} files modified in both branches" ) diff --git a/auto-claude/core/workspace.py b/auto-claude/core/workspace.py index 6a87973b..72edabc4 100644 --- a/auto-claude/core/workspace.py +++ b/auto-claude/core/workspace.py @@ -80,10 +80,11 @@ from core.workspace.display import ( show_build_summary, ) from core.workspace.git_utils import ( - get_changed_files_from_branch as _get_changed_files_from_branch, + _is_auto_claude_file, + get_existing_build_worktree, ) from core.workspace.git_utils import ( - get_existing_build_worktree, + get_changed_files_from_branch as _get_changed_files_from_branch, ) from core.workspace.git_utils import ( get_file_content_from_ref as _get_file_content_from_ref, @@ -548,7 +549,8 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict: ) if match: file_path = match.group(1).strip() - if file_path and file_path not in result["conflicting_files"]: + # Skip .auto-claude files - they should never be merged + if file_path and file_path not in result["conflicting_files"] and not _is_auto_claude_file(file_path): result["conflicting_files"].append(file_path) # Fallback: if we didn't parse conflicts, use diff to find files changed in both branches @@ -578,8 +580,9 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict: ) # Files modified in both = potential conflicts + # Filter out .auto-claude files - they should never be merged conflicting = main_files & spec_files - result["conflicting_files"] = list(conflicting) + result["conflicting_files"] = [f for f in conflicting if not _is_auto_claude_file(f)] except Exception as e: print(muted(f" Error checking git conflicts: {e}")) diff --git a/auto-claude/core/workspace/git_utils.py b/auto-claude/core/workspace/git_utils.py index 228c4afe..c29b2d19 100644 --- a/auto-claude/core/workspace/git_utils.py +++ b/auto-claude/core/workspace/git_utils.py @@ -142,8 +142,20 @@ def get_changed_files_from_branch( project_dir: Path, base_branch: str, spec_branch: str, + exclude_auto_claude: bool = True, ) -> list[tuple[str, str]]: - """Get list of changed files between branches.""" + """ + Get list of changed files between branches. + + Args: + project_dir: Project directory + base_branch: Base branch name + spec_branch: Spec branch name + exclude_auto_claude: If True, exclude .auto-claude directory files (default True) + + Returns: + List of (file_path, status) tuples + """ result = subprocess.run( ["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"], cwd=project_dir, @@ -157,10 +169,27 @@ def get_changed_files_from_branch( if line: parts = line.split("\t", 1) if len(parts) == 2: - files.append((parts[1], parts[0])) # (file_path, status) + file_path = parts[1] + # Exclude .auto-claude directory files from merge + if exclude_auto_claude and _is_auto_claude_file(file_path): + continue + files.append((file_path, parts[0])) # (file_path, status) return files +def _is_auto_claude_file(file_path: str) -> bool: + """Check if a file is in the .auto-claude or auto-claude/specs directory.""" + # These patterns cover the internal spec/build files that shouldn't be merged + excluded_patterns = [ + ".auto-claude/", + "auto-claude/specs/", + ] + for pattern in excluded_patterns: + if file_path.startswith(pattern): + return True + return False + + def is_process_running(pid: int) -> bool: """Check if a process with the given PID is running.""" import os diff --git a/auto-claude/core/worktree.py b/auto-claude/core/worktree.py index 2aaae53b..0e23c410 100644 --- a/auto-claude/core/worktree.py +++ b/auto-claude/core/worktree.py @@ -82,10 +82,11 @@ class WorktreeManager: def _unstage_gitignored_files(self) -> None: """ - Unstage any staged files that are gitignored in the current branch. + Unstage any staged files that are gitignored in the current branch, + plus any files in the .auto-claude directory which should never be merged. This is needed after a --no-commit merge because files that exist in the - source branch (like spec files in auto-claude/specs/) get staged even if + source branch (like spec files in .auto-claude/specs/) get staged even if they're gitignored in the target branch. """ # Get list of staged files @@ -95,10 +96,11 @@ class WorktreeManager: staged_files = result.stdout.strip().split("\n") - # Check which staged files are gitignored + # Files to unstage: gitignored files + .auto-claude directory files + files_to_unstage = set() + + # 1. Check which staged files are gitignored # git check-ignore returns the files that ARE ignored - result = self._run_git(["check-ignore", "--stdin"], cwd=self.project_dir) - # We need to pass the files via stdin result = subprocess.run( ["git", "check-ignore", "--stdin"], cwd=self.project_dir, @@ -107,17 +109,28 @@ class WorktreeManager: text=True, ) - if not result.stdout.strip(): - return - - ignored_files = result.stdout.strip().split("\n") - - if ignored_files: - print(f"Unstaging {len(ignored_files)} gitignored file(s)...") - # Unstage each ignored file - for file in ignored_files: + if result.stdout.strip(): + for file in result.stdout.strip().split("\n"): if file.strip(): - self._run_git(["reset", "HEAD", "--", file.strip()]) + files_to_unstage.add(file.strip()) + + # 2. Always unstage .auto-claude directory files - these are project-specific + # and should never be merged from the worktree branch + auto_claude_patterns = [".auto-claude/", "auto-claude/specs/"] + for file in staged_files: + file = file.strip() + if not file: + continue + for pattern in auto_claude_patterns: + if file.startswith(pattern) or f"/{pattern}" in file: + files_to_unstage.add(file) + break + + if files_to_unstage: + print(f"Unstaging {len(files_to_unstage)} auto-claude/gitignored file(s)...") + # Unstage each file + for file in files_to_unstage: + self._run_git(["reset", "HEAD", "--", file]) def setup(self) -> None: """Create worktrees directory if needed."""