Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 72 additions & 17 deletions apps/frontend/src/main/claude-profile/profile-scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +73 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve maintainability and adhere to the DRY (Don't Repeat Yourself) principle, consider extracting the logic for calculating effective thresholds into a helper function. This logic is duplicated in several places in this file (calculateFallbackScore, scoreUnifiedAccount, shouldProactivelySwitch) and also in apps/frontend/src/main/claude-profile/usage-monitor.ts in the checkThresholdsExceeded function.

A shared utility function could look like this:

function getEffectiveThresholds(settings: ClaudeAutoSwitchSettings) {
  const baseSession = settings.sessionThreshold ?? 95;
  const baseWeekly = settings.weeklyThreshold ?? 99;

  const effectiveSessionThreshold = settings.budgetCapPercent !== undefined
    ? Math.min(baseSession, settings.budgetCapPercent)
    : baseSession;
  const effectiveWeeklyThreshold = settings.budgetCapPercent !== undefined
    ? Math.min(baseWeekly, settings.budgetCapPercent)
    : baseWeekly;

  return { effectiveSessionThreshold, effectiveWeeklyThreshold };
}


// 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}%`
};
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
};
}
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/src/main/claude-profile/profile-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

/**
Expand Down
77 changes: 62 additions & 15 deletions apps/frontend/src/main/claude-profile/usage-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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;
Comment on lines +1113 to +1118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The UI allows setting budgetCapPercent to 0. This creates an impossible 0% usage threshold, marking all accounts as unavailable and triggering termination of all running agents.
Severity: HIGH

Suggested Fix

Prevent the budgetCapPercent from being set to 0. This can be done by adjusting the min value of the slider in the UI from 0 to a more sensible minimum, such as 1 or 5.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/frontend/src/main/claude-profile/usage-monitor.ts#L1113-L1118

Potential issue: The UI slider for `budgetCapPercent` in `AccountSettings.tsx` allows a
minimum value of 0. When this value is set, the logic in `usage-monitor.ts` calculates
effective usage thresholds using `Math.min(baseThreshold, 0)`, which results in 0.
Consequently, any account with usage greater than 0% is considered to have exceeded its
budget. This leads to all accounts being marked as unavailable. When the system tries to
find an alternative account and fails, it emits a `budget-exhausted` event, which in
turn calls `agentManager.killAll()` and terminates all active user agents.

Did we get this right? 👍 / 👎 to inform future reviews.


// 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);
Comment on lines 1105 to +1124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Budget-only mode is still enforcing proactive thresholds.

Line 1109 and Line 1110 always seed 95/99 (or configured) thresholds, so noExtraUsage-only mode can trigger exhaustion flow before 100%. That contradicts the “stop at 100%” behavior.

Suggested fix
-  private checkThresholdsExceeded(
+  private checkThresholdsExceeded(
     usage: ClaudeUsageSnapshot,
-    settings: { sessionThreshold?: number; weeklyThreshold?: number; budgetCapPercent?: number; noExtraUsage?: boolean }
+    settings: { sessionThreshold?: number; weeklyThreshold?: number; budgetCapPercent?: number; noExtraUsage?: boolean },
+    useProactiveThresholds: boolean
   ): { sessionExceeded: boolean; weeklyExceeded: boolean; anyExceeded: boolean } {
-    const baseSession = settings.sessionThreshold ?? 95;
-    const baseWeekly = settings.weeklyThreshold ?? 99;
+    const baseSession = useProactiveThresholds ? (settings.sessionThreshold ?? 95) : 100;
+    const baseWeekly = useProactiveThresholds ? (settings.weeklyThreshold ?? 99) : 100;
-        const thresholds = this.checkThresholdsExceeded(usage, settings);
+        const thresholds = this.checkThresholdsExceeded(usage, settings, isProactiveEnabled);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/main/claude-profile/usage-monitor.ts` around lines 1105 -
1124, The function checkThresholdsExceeded currently seeds
baseSession/baseWeekly to 95/99 which causes noExtraUsage-only mode to trigger
before 100%; change the baseline when settings.noExtraUsage is true so
thresholds default to 100 instead of 95/99. Concretely, in
checkThresholdsExceeded adjust how baseSession and baseWeekly are computed (or
override effectiveSession/effectiveWeekly) to use 100 when settings.noExtraUsage
is true, then apply the budgetCapPercent ceiling (Math.min) only afterwards if
budgetCapPercent is provided; keep the rest of the
sessionExceeded/weeklyExceeded checks unchanged and reference the existing
symbols baseSession, baseWeekly, effectiveSession, effectiveWeekly, and
settings.noExtraUsage.


return {
sessionExceeded,
Expand Down Expand Up @@ -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<void> {
const profileManager = getClaudeProfileManager();
const excludeIds = new Set([currentProfileId, ...additionalExclusions]);
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down
15 changes: 14 additions & 1 deletion apps/frontend/src/main/ipc-handlers/terminal-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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)
});
});
}
Loading
Loading