Feat/Usage-monitoring

This commit is contained in:
AndyMik90
2025-12-17 12:10:49 +01:00
parent eb77915879
commit de33b2c7c3
19 changed files with 1173 additions and 48 deletions
@@ -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;
}
}
+37 -3
View File
@@ -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();
}
+18
View File
@@ -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');
}
+37 -1
View File
@@ -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);
};
}
});
+7 -5
View File
@@ -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',
+39 -8
View File
@@ -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';
}
/**
+7 -6
View File
@@ -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"
)
+7 -4
View File
@@ -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}"))
+31 -2
View File
@@ -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
+28 -15
View File
@@ -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."""