diff --git a/apps/frontend/src/main/claude-profile/profile-scorer.ts b/apps/frontend/src/main/claude-profile/profile-scorer.ts index 1428df74ea..6033ae1c59 100644 --- a/apps/frontend/src/main/claude-profile/profile-scorer.ts +++ b/apps/frontend/src/main/claude-profile/profile-scorer.ts @@ -60,22 +60,40 @@ function checkProfileAvailability( // Check usage thresholds if (profile.usage) { + // noExtraUsage: hard stop — account unavailable as soon as either limit hits 100% + if (settings.noExtraUsage) { + if (profile.usage.weeklyUsagePercent >= 100) { + return { available: false, reason: 'no-extra-usage policy: weekly usage at 100% plan limit' }; + } + if (profile.usage.sessionUsagePercent >= 100) { + return { available: false, reason: 'no-extra-usage policy: session usage at 100% plan limit' }; + } + } + + // Effective thresholds: budget cap (if set) acts as a ceiling on both thresholds + const effectiveWeeklyThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.weeklyThreshold, settings.budgetCapPercent) + : settings.weeklyThreshold; + const effectiveSessionThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.sessionThreshold, settings.budgetCapPercent) + : settings.sessionThreshold; + // Weekly threshold check (more important - longer reset time) // Using >= to reject profiles AT or ABOVE threshold (e.g., 95% is rejected when threshold is 95%) // This is intentional: we want to switch proactively BEFORE hitting hard limits - if (profile.usage.weeklyUsagePercent >= settings.weeklyThreshold) { + if (profile.usage.weeklyUsagePercent >= effectiveWeeklyThreshold) { return { available: false, - reason: `weekly usage ${profile.usage.weeklyUsagePercent}% >= threshold ${settings.weeklyThreshold}%` + reason: `weekly usage ${profile.usage.weeklyUsagePercent}% >= threshold ${effectiveWeeklyThreshold}%` }; } // Session threshold check // Using >= to reject profiles AT or ABOVE threshold (same rationale as weekly) - if (profile.usage.sessionUsagePercent >= settings.sessionThreshold) { + if (profile.usage.sessionUsagePercent >= effectiveSessionThreshold) { return { available: false, - reason: `session usage ${profile.usage.sessionUsagePercent}% >= threshold ${settings.sessionThreshold}%` + reason: `session usage ${profile.usage.sessionUsagePercent}% >= threshold ${effectiveSessionThreshold}%` }; } } @@ -117,9 +135,16 @@ function calculateFallbackScore( // Usage penalties (prefer lower usage) if (profile.usage) { + const effectiveWeeklyThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.weeklyThreshold, settings.budgetCapPercent) + : settings.weeklyThreshold; + const effectiveSessionThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.sessionThreshold, settings.budgetCapPercent) + : settings.sessionThreshold; + // Penalize based on how far over threshold - const weeklyOverage = Math.max(0, profile.usage.weeklyUsagePercent - settings.weeklyThreshold); - const sessionOverage = Math.max(0, profile.usage.sessionUsagePercent - settings.sessionThreshold); + const weeklyOverage = Math.max(0, profile.usage.weeklyUsagePercent - effectiveWeeklyThreshold); + const sessionOverage = Math.max(0, profile.usage.sessionUsagePercent - effectiveSessionThreshold); score -= weeklyOverage * 2; // Weekly overage is worse score -= sessionOverage; @@ -206,13 +231,34 @@ function scoreUnifiedAccount( } unavailableReason = `rate limited (${account.rateLimitType || 'unknown'})`; } else { - // Check usage thresholds (matching checkProfileAvailability behavior) - if (account.weeklyPercent !== undefined && account.weeklyPercent >= settings.weeklyThreshold) { - isOverThreshold = true; - unavailableReason = `weekly usage ${account.weeklyPercent}% >= threshold ${settings.weeklyThreshold}%`; - } else if (account.sessionPercent !== undefined && account.sessionPercent >= settings.sessionThreshold) { - isOverThreshold = true; - unavailableReason = `session usage ${account.sessionPercent}% >= threshold ${settings.sessionThreshold}%`; + // noExtraUsage: hard stop at 100% + if (settings.noExtraUsage) { + if (account.weeklyPercent !== undefined && account.weeklyPercent >= 100) { + isOverThreshold = true; + unavailableReason = 'no-extra-usage policy: weekly usage at 100% plan limit'; + } else if (account.sessionPercent !== undefined && account.sessionPercent >= 100) { + isOverThreshold = true; + unavailableReason = 'no-extra-usage policy: session usage at 100% plan limit'; + } + } + + if (!isOverThreshold) { + // Effective thresholds: budget cap acts as a ceiling on both thresholds + const effectiveWeeklyThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.weeklyThreshold, settings.budgetCapPercent) + : settings.weeklyThreshold; + const effectiveSessionThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.sessionThreshold, settings.budgetCapPercent) + : settings.sessionThreshold; + + // Check usage thresholds (matching checkProfileAvailability behavior) + if (account.weeklyPercent !== undefined && account.weeklyPercent >= effectiveWeeklyThreshold) { + isOverThreshold = true; + unavailableReason = `weekly usage ${account.weeklyPercent}% >= threshold ${effectiveWeeklyThreshold}%`; + } else if (account.sessionPercent !== undefined && account.sessionPercent >= effectiveSessionThreshold) { + isOverThreshold = true; + unavailableReason = `session usage ${account.sessionPercent}% >= threshold ${effectiveSessionThreshold}%`; + } } // Apply proportional penalties for high usage (even if not over threshold) @@ -483,24 +529,33 @@ export function shouldProactivelySwitch( const usage = profile.usage; + // Effective thresholds: budget cap acts as a ceiling; noExtraUsage caps at 100% + const effectiveWeeklyThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.weeklyThreshold, settings.budgetCapPercent) + : settings.weeklyThreshold; + const effectiveSessionThreshold = settings.budgetCapPercent !== undefined + ? Math.min(settings.sessionThreshold, settings.budgetCapPercent) + : settings.sessionThreshold; // Check if we're approaching limits - if (usage.weeklyUsagePercent >= settings.weeklyThreshold) { + if (usage.weeklyUsagePercent >= effectiveWeeklyThreshold || + (settings.noExtraUsage && usage.weeklyUsagePercent >= 100)) { const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder); if (bestProfile) { return { shouldSwitch: true, - reason: `Weekly usage at ${usage.weeklyUsagePercent}% (threshold: ${settings.weeklyThreshold}%)`, + reason: `Weekly usage at ${usage.weeklyUsagePercent}% (threshold: ${effectiveWeeklyThreshold}%)`, suggestedProfile: bestProfile }; } } - if (usage.sessionUsagePercent >= settings.sessionThreshold) { + if (usage.sessionUsagePercent >= effectiveSessionThreshold || + (settings.noExtraUsage && usage.sessionUsagePercent >= 100)) { const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder); if (bestProfile) { return { shouldSwitch: true, - reason: `Session usage at ${usage.sessionUsagePercent}% (threshold: ${settings.sessionThreshold}%)`, + reason: `Session usage at ${usage.sessionUsagePercent}% (threshold: ${effectiveSessionThreshold}%)`, suggestedProfile: bestProfile }; } diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index ed9d798846..f36a84a967 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -27,7 +27,9 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = { weeklyThreshold: 99, // Consider switching at 99% weekly usage autoSwitchOnRateLimit: false, // Prompt user by default autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures - usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled) + usageCheckInterval: 30000, // Check every 30s when enabled (0 = disabled) + budgetCapPercent: undefined, // Disabled by default; when set, caps both thresholds + noExtraUsage: false, // Allow Anthropic extra usage by default }; /** diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 0700307408..e4b5729a13 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -927,32 +927,55 @@ export class UsageMonitor extends EventEmitter { const profileManager = getClaudeProfileManager(); const settings = profileManager.getAutoSwitchSettings(); - if (!settings.enabled || !settings.proactiveSwapEnabled) { - this.debugLog('[UsageMonitor:TRACE] Proactive swap disabled, skipping threshold check'); + // Budget policy (budgetCapPercent / noExtraUsage) runs independently of the + // auto-switch master toggle — a single-account user can still enforce a hard stop. + const hasBudgetPolicy = settings.budgetCapPercent !== undefined || settings.noExtraUsage; + const isProactiveEnabled = settings.enabled && settings.proactiveSwapEnabled; + + if (!hasBudgetPolicy && !isProactiveEnabled) { + this.debugLog('[UsageMonitor:TRACE] Proactive swap and budget policy both disabled, skipping threshold check'); return; } const thresholds = this.checkThresholdsExceeded(usage, settings); if (thresholds.anyExceeded) { + const limitLabel = thresholds.sessionExceeded ? 'session' : 'weekly'; + const limitPercent = thresholds.sessionExceeded ? usage.sessionPercent : usage.weeklyPercent; + const capLabel = settings.noExtraUsage + ? 'noExtraUsage (100%)' + : settings.budgetCapPercent !== undefined + ? `budgetCap (${settings.budgetCapPercent}%)` + : `threshold (${thresholds.sessionExceeded ? (settings.sessionThreshold ?? 95) : (settings.weeklyThreshold ?? 99)}%)`; + console.warn( + `[UsageMonitor] Budget limit reached: ${limitLabel} usage at ${limitPercent.toFixed(1)}% exceeds ${capLabel} for profile "${profileId}". ${hasBudgetPolicy ? 'Will stop agents if no alternative account.' : 'Will attempt account switch.'}` + ); + this.debugLog('[UsageMonitor:TRACE] Threshold exceeded', { sessionPercent: usage.sessionPercent, weekPercent: usage.weeklyPercent, activeProfile: profileId, - hasCredential: !!credential + hasCredential: !!credential, + hasBudgetPolicy, + isProactiveEnabled }); this.debugLog('[UsageMonitor] Threshold exceeded:', { sessionPercent: usage.sessionPercent, sessionThreshold: settings.sessionThreshold ?? 95, weeklyPercent: usage.weeklyPercent, - weeklyThreshold: settings.weeklyThreshold ?? 99 + weeklyThreshold: settings.weeklyThreshold ?? 99, + budgetCapPercent: settings.budgetCapPercent, + noExtraUsage: settings.noExtraUsage }); - // Attempt proactive swap + // Attempt proactive swap; pass stopIfExhausted=true when a budget policy is active + // so that running agents are killed if no alternative account is available. await this.performProactiveSwap( profileId, - thresholds.sessionExceeded ? 'session' : 'weekly' + thresholds.sessionExceeded ? 'session' : 'weekly', + [], + hasBudgetPolicy ); } else { this.debugLog('[UsageMonitor:TRACE] Usage OK', { @@ -1081,10 +1104,24 @@ export class UsageMonitor extends EventEmitter { */ private checkThresholdsExceeded( usage: ClaudeUsageSnapshot, - settings: { sessionThreshold?: number; weeklyThreshold?: number } + settings: { sessionThreshold?: number; weeklyThreshold?: number; budgetCapPercent?: number; noExtraUsage?: boolean } ): { sessionExceeded: boolean; weeklyExceeded: boolean; anyExceeded: boolean } { - const sessionExceeded = usage.sessionPercent >= (settings.sessionThreshold ?? 95); - const weeklyExceeded = usage.weeklyPercent >= (settings.weeklyThreshold ?? 99); + const baseSession = settings.sessionThreshold ?? 95; + const baseWeekly = settings.weeklyThreshold ?? 99; + + // Budget cap acts as a ceiling on both thresholds + const effectiveSession = settings.budgetCapPercent !== undefined + ? Math.min(baseSession, settings.budgetCapPercent) + : baseSession; + const effectiveWeekly = settings.budgetCapPercent !== undefined + ? Math.min(baseWeekly, settings.budgetCapPercent) + : baseWeekly; + + // noExtraUsage: also flag when hitting 100% + const sessionExceeded = usage.sessionPercent >= effectiveSession || + (!!settings.noExtraUsage && usage.sessionPercent >= 100); + const weeklyExceeded = usage.weeklyPercent >= effectiveWeekly || + (!!settings.noExtraUsage && usage.weeklyPercent >= 100); return { sessionExceeded, @@ -1867,7 +1904,8 @@ export class UsageMonitor extends EventEmitter { private async performProactiveSwap( currentProfileId: string, limitType: 'session' | 'weekly', - additionalExclusions: string[] = [] + additionalExclusions: string[] = [], + stopIfExhausted: boolean = false ): Promise { const profileManager = getClaudeProfileManager(); const excludeIds = new Set([currentProfileId, ...additionalExclusions]); @@ -1924,11 +1962,20 @@ export class UsageMonitor extends EventEmitter { if (unifiedAccounts.length === 0) { this.debugLog('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds)); - this.emit('proactive-swap-failed', { - reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative', - currentProfile: currentProfileId, - excludedProfiles: Array.from(excludeIds) - }); + if (stopIfExhausted) { + // Budget policy: no account to switch to — emit budget-exhausted so running agents are stopped. + this.emit('budget-exhausted', { + reason: 'budget_cap_no_alternative', + currentProfile: currentProfileId, + limitType + }); + } else { + this.emit('proactive-swap-failed', { + reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative', + currentProfile: currentProfileId, + excludedProfiles: Array.from(excludeIds) + }); + } return; } diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index c8644ed8a9..44f5ebc3a3 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -506,7 +506,7 @@ app.whenReady().then(() => { // Only start monitoring if window is still available (app not quitting) if (mainWindow) { // Setup event forwarding from usage monitor to renderer - initializeUsageMonitorForwarding(mainWindow); + initializeUsageMonitorForwarding(mainWindow, agentManager); // Start the usage monitor (uses unified OperationRegistry for proactive restart) const usageMonitor = getUsageMonitor(); @@ -561,7 +561,7 @@ app.whenReady().then(() => { console.warn('[main] Failed to initialize profile manager:', error); // Fallback: try starting usage monitor anyway (might use defaults) if (mainWindow) { - initializeUsageMonitorForwarding(mainWindow); + initializeUsageMonitorForwarding(mainWindow, agentManager); const usageMonitor = getUsageMonitor(); usageMonitor.start(); } diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 5aca822539..650e00fc90 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -4,6 +4,7 @@ import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; +import type { AgentManager } from '../agent'; import { TerminalManager } from '../terminal-manager'; import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; @@ -736,7 +737,7 @@ export function registerTerminalHandlers( * Initialize usage monitor event forwarding to renderer process * Call this after mainWindow is created */ -export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): void { +export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow, agentManager?: AgentManager | null): void { const monitor = getUsageMonitor(); // Forward usage updates to renderer @@ -753,4 +754,16 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi monitor.on('show-swap-notification', (notification: unknown) => { mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification); }); + + // Budget exhausted: no account to switch to — stop all running agents + monitor.on('budget-exhausted', (payload: unknown) => { + console.warn('[UsageMonitor] Budget exhausted, stopping all running agents:', payload); + agentManager?.killAll().catch((err: unknown) => { + console.error('[UsageMonitor] Failed to kill agents after budget exhaustion:', err); + }); + mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, { + type: 'budget_exhausted', + ...(payload as object) + }); + }); } diff --git a/apps/frontend/src/renderer/components/settings/AccountSettings.tsx b/apps/frontend/src/renderer/components/settings/AccountSettings.tsx index c59c3232d8..667a3b8709 100644 --- a/apps/frontend/src/renderer/components/settings/AccountSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AccountSettings.tsx @@ -1269,6 +1269,68 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS + {/* Usage Limits Section - Always visible, independent of account count */} +
+
+ +

{t('accounts.usageLimits.title')}

+
+ +
+

+ {t('accounts.usageLimits.description')} +

+ + {/* Budget cap */} +
+
+ + + {autoSwitchSettings?.budgetCapPercent !== undefined + ? `${autoSwitchSettings.budgetCapPercent}%` + : t('accounts.autoSwitching.budgetCapOff')} + +
+ { + const val = parseInt(e.target.value, 10); + handleUpdateAutoSwitch({ budgetCapPercent: val >= 100 ? undefined : val }); + }} + disabled={isLoadingAutoSwitch} + className="w-full" + aria-describedby="budget-cap-description" + /> +

+ {t('accounts.autoSwitching.budgetCapDescription')} +

+
+ + {/* No extra usage */} +
+
+ +

+ {t('accounts.autoSwitching.noExtraUsageDescription')} +

+
+ handleUpdateAutoSwitch({ noExtraUsage: value })} + disabled={isLoadingAutoSwitch} + /> +
+
+
+ {/* Auto-Switch Settings Section - Persistent below tabs */} {totalAccounts > 1 && (
@@ -1365,6 +1427,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS {t('accounts.autoSwitching.weeklyThresholdDescription')}

+ )} diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index bc7fd8fa8f..a8d99fd933 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -572,6 +572,10 @@ "deleting": "Deleting..." } }, + "usageLimits": { + "title": "Usage Limits", + "description": "Cap how much of your plan is consumed. Works with any number of accounts — stops running agents when the limit is reached and no alternative account is available." + }, "autoSwitching": { "title": "Automatic Account Switching", "description": "Automatically switch between accounts to avoid interruptions. Configure proactive monitoring to switch before hitting limits.", @@ -583,6 +587,11 @@ "sessionThresholdDescription": "Switch when session usage reaches this level (recommended: 95%)", "weeklyThreshold": "Weekly usage threshold", "weeklyThresholdDescription": "Switch when weekly usage reaches this level (recommended: 99%)", + "budgetCap": "Budget cap", + "budgetCapOff": "Off", + "budgetCapDescription": "Single cap for both session and weekly usage — overrides individual thresholds when stricter. Drag to 100% to disable.", + "noExtraUsage": "Block extra usage", + "noExtraUsageDescription": "Stop this account at 100% to prevent Anthropic's pay-per-use overage charges (extra usage).", "reactiveRecovery": "Reactive Recovery", "reactiveDescription": "Auto-swap when unexpected rate limit is hit", "autoSwitchOnAuthFailure": "Auto-switch on auth failure", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 8d506e900f..0528662dc0 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -572,6 +572,10 @@ "deleting": "Suppression..." } }, + "usageLimits": { + "title": "Limites d'utilisation", + "description": "Limitez la consommation de votre forfait. Fonctionne avec n'importe quel nombre de comptes — arrête les agents en cours si la limite est atteinte et qu'aucun compte alternatif n'est disponible." + }, "autoSwitching": { "title": "Basculement automatique de compte", "description": "Basculez automatiquement entre les comptes pour éviter les interruptions. Configurez la surveillance proactive pour basculer avant d'atteindre les limites.", @@ -583,6 +587,11 @@ "sessionThresholdDescription": "Basculer lorsque l'utilisation de session atteint ce niveau (recommandé: 95%)", "weeklyThreshold": "Seuil d'utilisation hebdomadaire", "weeklyThresholdDescription": "Basculer lorsque l'utilisation hebdomadaire atteint ce niveau (recommandé: 99%)", + "budgetCap": "Plafond budgétaire", + "budgetCapOff": "Désactivé", + "budgetCapDescription": "Plafond unique pour l'utilisation de session et hebdomadaire — remplace les seuils individuels s'il est plus strict. Glisser à 100% pour désactiver.", + "noExtraUsage": "Bloquer l'utilisation supplémentaire", + "noExtraUsageDescription": "Arrêter ce compte à 100% pour éviter les frais de dépassement payants d'Anthropic (utilisation supplémentaire).", "reactiveRecovery": "Récupération réactive", "reactiveDescription": "Auto-basculement en cas de limite de taux inattendue", "autoSwitchOnAuthFailure": "Changement auto en cas d'échec d'auth", diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index e4448450bd..8acb4ae235 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -234,6 +234,23 @@ export interface ClaudeAutoSwitchSettings { /** Whether to automatically switch on authentication failure (vs. prompting user) */ autoSwitchOnAuthFailure: boolean; + + /** + * Unified budget cap (0-100). When set, acts as a ceiling on BOTH session and weekly + * thresholds — no account will be used beyond this % of either limit. + * Provides a single slider to limit overall plan consumption instead of tuning two + * thresholds separately. When undefined, the individual thresholds are used as-is. + */ + budgetCapPercent?: number; + + /** + * When true, treats usage at 100% as unavailable — prevents Anthropic's "extra usage" + * (pay-per-use overage beyond plan limits) from being consumed. + * The account is considered unavailable as soon as either session or weekly usage + * reaches 100%, so it will be switched away from before any overage charges occur. + * Default: false + */ + noExtraUsage: boolean; } export interface ClaudeAuthResult {