Feat/Usage-monitoring
This commit is contained in:
@@ -23,6 +23,16 @@ export class AgentManager extends EventEmitter {
|
||||
private events: AgentEvents;
|
||||
private processManager: AgentProcessManager;
|
||||
private queueManager: AgentQueueManager;
|
||||
private taskExecutionContext: Map<string, {
|
||||
projectPath: string;
|
||||
specId: string;
|
||||
options: TaskExecutionOptions;
|
||||
isSpecCreation?: boolean;
|
||||
taskDescription?: string;
|
||||
specDir?: string;
|
||||
metadata?: SpecCreationMetadata;
|
||||
swapCount: number;
|
||||
}> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -51,3 +51,6 @@ export {
|
||||
hasValidToken,
|
||||
expandHomePath
|
||||
} from './profile-utils';
|
||||
|
||||
// Usage monitoring (proactive account switching)
|
||||
export { UsageMonitor, getUsageMonitor } from './usage-monitor';
|
||||
|
||||
@@ -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<void> {
|
||||
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<ClaudeUsageSnapshot | null> {
|
||||
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<ClaudeUsageSnapshot | null> {
|
||||
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<ClaudeUsageSnapshot | null> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> => {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ export interface TerminalAPI {
|
||||
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>>;
|
||||
onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void;
|
||||
retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise<IPCResult>;
|
||||
|
||||
// Usage Monitoring (Proactive Account Switching)
|
||||
requestUsageUpdate: () => Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>>;
|
||||
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<IPCResult> =>
|
||||
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<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> =>
|
||||
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);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<ProactiveSwapListener />
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
@@ -273,7 +276,8 @@ export function App() {
|
||||
)}
|
||||
</div>
|
||||
{selectedProject && (
|
||||
<div className="electron-no-drag">
|
||||
<div className="electron-no-drag flex items-center gap-3">
|
||||
<UsageIndicator />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -397,8 +401,6 @@ export function App() {
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleSkipInit();
|
||||
} else {
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Proactive Swap Listener - Listens for and displays proactive swap notifications
|
||||
*
|
||||
* When a proactive account swap occurs (before hitting rate limits),
|
||||
* this component shows a brief notification to inform the user.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface SwapNotification {
|
||||
fromProfile: string;
|
||||
toProfile: string;
|
||||
reason: 'proactive' | 'reactive';
|
||||
limitType: 'session' | 'weekly';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export function ProactiveSwapListener() {
|
||||
const [notification, setNotification] = useState<SwapNotification | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI.onProactiveSwapNotification((data: any) => {
|
||||
const notif: SwapNotification = {
|
||||
fromProfile: data.fromProfile,
|
||||
toProfile: data.toProfile,
|
||||
reason: data.reason,
|
||||
limitType: data.limitType,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setNotification(notif);
|
||||
setIsVisible(true);
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (!notification || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 animate-in slide-in-from-top-2 fade-in duration-300">
|
||||
<div className="bg-card border border-border shadow-lg rounded-lg p-4 max-w-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">Account Switched</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Switched from <strong>{notification.fromProfile}</strong> to{' '}
|
||||
<strong>{notification.toProfile}</strong>
|
||||
<br />
|
||||
<span className="text-[10px]">
|
||||
({notification.limitType} usage threshold reached)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Swap notification info */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
{swapInfo?.wasAutoSwapped ? (
|
||||
<>
|
||||
<p className="font-medium mb-1">
|
||||
{swapInfo.swapReason === 'proactive' ? '✓ Proactive Swap' : '⚡ Reactive Swap'}
|
||||
</p>
|
||||
<p>
|
||||
{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.`
|
||||
}
|
||||
</p>
|
||||
<p className="mt-2 text-[10px]">
|
||||
Your work continued without interruption.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium mb-1">Rate limit reached</p>
|
||||
<p>
|
||||
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.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upgrade button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2 w-full"
|
||||
onClick={() => window.open(CLAUDE_UPGRADE_URL, '_blank')}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
Upgrade to Pro for Higher Limits
|
||||
</Button>
|
||||
|
||||
{/* Reset time info */}
|
||||
{sdkRateLimitInfo.resetTime && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
|
||||
|
||||
@@ -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<ClaudeUsageSnapshot | null>(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 (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${colorClasses}`}
|
||||
aria-label="Claude usage status"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold font-mono">
|
||||
{Math.round(maxUsage)}%
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs w-64">
|
||||
<div className="space-y-2">
|
||||
{/* Session usage */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 mb-1">
|
||||
<span className="text-muted-foreground font-medium">Session Usage</span>
|
||||
<span className="font-semibold tabular-nums">{Math.round(usage.sessionPercent)}%</span>
|
||||
</div>
|
||||
{usage.sessionResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Resets: {usage.sessionResetTime}
|
||||
</div>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
usage.sessionPercent >= 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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Weekly usage */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 mb-1">
|
||||
<span className="text-muted-foreground font-medium">Weekly Usage</span>
|
||||
<span className="font-semibold tabular-nums">{Math.round(usage.weeklyPercent)}%</span>
|
||||
</div>
|
||||
{usage.weeklyResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Resets: {usage.weeklyResetTime}
|
||||
</div>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
usage.weeklyPercent >= 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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Active profile */}
|
||||
<div className="flex items-center justify-between gap-4 pt-1">
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">Active Account</span>
|
||||
<span className="font-semibold text-primary">{usage.profileName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
// Load Claude profiles when section is shown
|
||||
// Auto-swap settings state
|
||||
const [autoSwitchSettings, setAutoSwitchSettings] = useState<ClaudeAutoSwitchSettings | null>(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<ClaudeAutoSwitchSettings>) => {
|
||||
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 (
|
||||
<SettingsSection
|
||||
title="Integrations"
|
||||
@@ -543,6 +585,144 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Switch Settings Section */}
|
||||
{claudeProfiles.length > 1 && (
|
||||
<div className="space-y-4 pt-6 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-semibold text-foreground">Automatic Account Switching</h4>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/30 border border-border p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically switch between Claude accounts to avoid interruptions.
|
||||
Configure proactive monitoring to switch before hitting limits.
|
||||
</p>
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Enable automatic switching</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Master switch for all auto-swap features
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSwitchSettings?.enabled ?? false}
|
||||
onCheckedChange={(enabled) => handleUpdateAutoSwitch({ enabled })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{autoSwitchSettings?.enabled && (
|
||||
<>
|
||||
{/* Proactive Monitoring Section */}
|
||||
<div className="pl-6 space-y-4 pt-2 border-l-2 border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Proactive Monitoring
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Check usage regularly and swap before hitting limits
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSwitchSettings?.proactiveSwapEnabled ?? true}
|
||||
onCheckedChange={(value) => handleUpdateAutoSwitch({ proactiveSwapEnabled: value })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{autoSwitchSettings?.proactiveSwapEnabled && (
|
||||
<>
|
||||
{/* Check interval */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Check usage every</Label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-background border border-input rounded-md text-sm"
|
||||
value={autoSwitchSettings?.usageCheckInterval ?? 30000}
|
||||
onChange={(e) => handleUpdateAutoSwitch({ usageCheckInterval: parseInt(e.target.value) })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
>
|
||||
<option value={15000}>15 seconds</option>
|
||||
<option value={30000}>30 seconds (recommended)</option>
|
||||
<option value={60000}>1 minute</option>
|
||||
<option value={0}>Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Session threshold */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Session usage threshold</Label>
|
||||
<span className="text-sm font-mono">{autoSwitchSettings?.sessionThreshold ?? 95}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="70"
|
||||
max="99"
|
||||
step="1"
|
||||
value={autoSwitchSettings?.sessionThreshold ?? 95}
|
||||
onChange={(e) => handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Switch when session usage reaches this level (recommended: 95%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Weekly threshold */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Weekly usage threshold</Label>
|
||||
<span className="text-sm font-mono">{autoSwitchSettings?.weeklyThreshold ?? 99}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="70"
|
||||
max="99"
|
||||
step="1"
|
||||
value={autoSwitchSettings?.weeklyThreshold ?? 99}
|
||||
onChange={(e) => handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Switch when weekly usage reaches this level (recommended: 99%)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reactive Recovery Section */}
|
||||
<div className="pl-6 space-y-4 pt-2 border-l-2 border-orange-500/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Reactive Recovery
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Auto-swap when unexpected rate limit is hit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSwitchSettings?.autoSwitchOnRateLimit ?? false}
|
||||
onCheckedChange={(value) => handleUpdateAutoSwitch({ autoSwitchOnRateLimit: value })}
|
||||
disabled={isLoadingAutoSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div className="space-y-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user