diff --git a/apps/server/src/providers/cursor-config-manager.ts b/apps/server/src/providers/cursor-config-manager.ts index aa57d2b63..7b32ceb95 100644 --- a/apps/server/src/providers/cursor-config-manager.ts +++ b/apps/server/src/providers/cursor-config-manager.ts @@ -44,7 +44,7 @@ export class CursorConfigManager { // Return default config with all available models return { - defaultModel: 'auto', + defaultModel: 'cursor-auto', models: getAllCursorModelIds(), }; } @@ -77,7 +77,7 @@ export class CursorConfigManager { * Get the default model */ getDefaultModel(): CursorModelId { - return this.config.defaultModel || 'auto'; + return this.config.defaultModel || 'cursor-auto'; } /** @@ -93,7 +93,7 @@ export class CursorConfigManager { * Get enabled models */ getEnabledModels(): CursorModelId[] { - return this.config.models || ['auto']; + return this.config.models || ['cursor-auto']; } /** @@ -174,7 +174,7 @@ export class CursorConfigManager { */ reset(): void { this.config = { - defaultModel: 'auto', + defaultModel: 'cursor-auto', models: getAllCursorModelIds(), }; this.saveConfig(); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 16dbd1972..e587a0613 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js'; import { createStopFeatureHandler } from './routes/stop-feature.js'; import { createStatusHandler } from './routes/status.js'; import { createRunFeatureHandler } from './routes/run-feature.js'; +import { createStartHandler } from './routes/start.js'; +import { createStopHandler } from './routes/stop.js'; import { createVerifyFeatureHandler } from './routes/verify-feature.js'; import { createResumeFeatureHandler } from './routes/resume-feature.js'; import { createContextExistsHandler } from './routes/context-exists.js'; @@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); + // Auto loop control routes + router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService)); + router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService)); + router.post('/stop-feature', createStopFeatureHandler(autoModeService)); router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService)); router.post( diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts new file mode 100644 index 000000000..405a31b29 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -0,0 +1,54 @@ +/** + * POST /start endpoint - Start auto mode loop for a project + */ + +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createStartHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, maxConcurrency } = req.body as { + projectPath: string; + maxConcurrency?: number; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Check if already running + if (autoModeService.isAutoLoopRunningForProject(projectPath)) { + res.json({ + success: true, + message: 'Auto mode is already running for this project', + alreadyRunning: true, + }); + return; + } + + // Start the auto loop for this project + await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3); + + logger.info( + `Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}` + ); + + res.json({ + success: true, + message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`, + }); + } catch (error) { + logError(error, 'Start auto mode failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index 9a1b46903..a2ccd8327 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -1,5 +1,8 @@ /** * POST /status endpoint - Get auto mode status + * + * If projectPath is provided, returns per-project status including autoloop state. + * If no projectPath, returns global status for backward compatibility. */ import type { Request, Response } from 'express'; @@ -9,10 +12,30 @@ import { getErrorMessage, logError } from '../common.js'; export function createStatusHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { + const { projectPath } = req.body as { projectPath?: string }; + + // If projectPath is provided, return per-project status + if (projectPath) { + const projectStatus = autoModeService.getStatusForProject(projectPath); + res.json({ + success: true, + isRunning: projectStatus.runningCount > 0, + isAutoLoopRunning: projectStatus.isAutoLoopRunning, + runningFeatures: projectStatus.runningFeatures, + runningCount: projectStatus.runningCount, + maxConcurrency: projectStatus.maxConcurrency, + projectPath, + }); + return; + } + + // Fall back to global status for backward compatibility const status = autoModeService.getStatus(); + const activeProjects = autoModeService.getActiveAutoLoopProjects(); res.json({ success: true, ...status, + activeAutoLoopProjects: activeProjects, }); } catch (error) { logError(error, 'Get status failed'); diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts new file mode 100644 index 000000000..79f074a84 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -0,0 +1,54 @@ +/** + * POST /stop endpoint - Stop auto mode loop for a project + */ + +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createStopHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Check if running + if (!autoModeService.isAutoLoopRunningForProject(projectPath)) { + res.json({ + success: true, + message: 'Auto mode is not running for this project', + wasRunning: false, + }); + return; + } + + // Stop the auto loop for this project + const runningCount = await autoModeService.stopAutoLoopForProject(projectPath); + + logger.info( + `Stopped auto loop for project: ${projectPath}, ${runningCount} features still running` + ); + + res.json({ + success: true, + message: 'Auto mode stopped', + runningFeaturesCount: runningCount, + }); + } catch (error) { + logError(error, 'Stop auto mode failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index 1fab1e2a2..a1797a3fb 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -100,11 +100,60 @@ export function getAbortController(): AbortController | null { return currentAbortController; } +/** + * Map SDK/CLI errors to user-friendly messages + */ +export function mapBacklogPlanError(rawMessage: string): string { + // Claude Code spawn failures + if ( + rawMessage.includes('Failed to spawn Claude Code process') || + rawMessage.includes('spawn node ENOENT') || + rawMessage.includes('Claude Code executable not found') || + rawMessage.includes('Claude Code native binary not found') + ) { + return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.'; + } + + // Claude Code process crash + if (rawMessage.includes('Claude Code process exited')) { + return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.'; + } + + // Rate limiting + if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) { + return 'Rate limited. Please wait a moment and try again.'; + } + + // Network errors + if ( + rawMessage.toLowerCase().includes('network') || + rawMessage.toLowerCase().includes('econnrefused') || + rawMessage.toLowerCase().includes('timeout') + ) { + return 'Network error. Check your internet connection and try again.'; + } + + // Authentication errors + if ( + rawMessage.toLowerCase().includes('not authenticated') || + rawMessage.toLowerCase().includes('unauthorized') || + rawMessage.includes('401') + ) { + return 'Authentication failed. Please check your API key or run `claude login` to authenticate.'; + } + + // Return original message for unknown errors + return rawMessage; +} + export function getErrorMessage(error: unknown): string { + let rawMessage: string; if (error instanceof Error) { - return error.message; + rawMessage = error.message; + } else { + rawMessage = String(error); } - return String(error); + return mapBacklogPlanError(rawMessage); } export function logError(error: unknown, context: string): void { diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index 0e9218e68..cd67d3dbf 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -53,13 +53,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se setRunningState(true, abortController); // Start generation in background + // Note: generateBacklogPlan handles its own error event emission, + // so we only log here to avoid duplicate error toasts generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model) .catch((error) => { + // Just log - error event already emitted by generateBacklogPlan logError(error, 'Generate backlog plan failed (background)'); - events.emit('backlog-plan:event', { - type: 'backlog_plan_error', - error: getErrorMessage(error), - }); }) .finally(() => { setRunningState(false, null); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 454b7ec0f..59af78727 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -235,6 +235,17 @@ interface AutoModeConfig { projectPath: string; } +/** + * Per-project autoloop state for multi-project support + */ +interface ProjectAutoLoopState { + abortController: AbortController; + config: AutoModeConfig; + isRunning: boolean; + consecutiveFailures: { timestamp: number; error: string }[]; + pausedDueToFailures: boolean; +} + /** * Execution state for recovery after server restart * Tracks which features were running and auto-loop configuration @@ -267,12 +278,15 @@ export class AutoModeService { private runningFeatures = new Map(); private autoLoop: AutoLoopState | null = null; private featureLoader = new FeatureLoader(); + // Per-project autoloop state (supports multiple concurrent projects) + private autoLoopsByProject = new Map(); + // Legacy single-project properties (kept for backward compatibility during transition) private autoLoopRunning = false; private autoLoopAbortController: AbortController | null = null; private config: AutoModeConfig | null = null; private pendingApprovals = new Map(); private settingsService: SettingsService | null = null; - // Track consecutive failures to detect quota/API issues + // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) private consecutiveFailures: { timestamp: number; error: string }[] = []; private pausedDueToFailures = false; @@ -284,6 +298,44 @@ export class AutoModeService { /** * Track a failure and check if we should pause due to consecutive failures. * This handles cases where the SDK doesn't return useful error messages. + * @param projectPath - The project to track failure for + * @param errorInfo - Error information + */ + private trackFailureAndCheckPauseForProject( + projectPath: string, + errorInfo: { type: string; message: string } + ): boolean { + const projectState = this.autoLoopsByProject.get(projectPath); + if (!projectState) { + // Fall back to legacy global tracking + return this.trackFailureAndCheckPause(errorInfo); + } + + const now = Date.now(); + + // Add this failure + projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); + + // Remove old failures outside the window + projectState.consecutiveFailures = projectState.consecutiveFailures.filter( + (f) => now - f.timestamp < FAILURE_WINDOW_MS + ); + + // Check if we've hit the threshold + if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { + return true; // Should pause + } + + // Also immediately pause for known quota/rate limit errors + if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { + return true; + } + + return false; + } + + /** + * Track a failure and check if we should pause due to consecutive failures (legacy global). */ private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean { const now = Date.now(); @@ -311,7 +363,49 @@ export class AutoModeService { /** * Signal that we should pause due to repeated failures or quota exhaustion. - * This will pause the auto loop to prevent repeated failures. + * This will pause the auto loop for a specific project. + * @param projectPath - The project to pause + * @param errorInfo - Error information + */ + private signalShouldPauseForProject( + projectPath: string, + errorInfo: { type: string; message: string } + ): void { + const projectState = this.autoLoopsByProject.get(projectPath); + if (!projectState) { + // Fall back to legacy global pause + this.signalShouldPause(errorInfo); + return; + } + + if (projectState.pausedDueToFailures) { + return; // Already paused + } + + projectState.pausedDueToFailures = true; + const failureCount = projectState.consecutiveFailures.length; + logger.info( + `Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` + ); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_paused_failures', { + message: + failureCount >= CONSECUTIVE_FAILURE_THRESHOLD + ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` + : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', + errorType: errorInfo.type, + originalError: errorInfo.message, + failureCount, + projectPath, + }); + + // Stop the auto loop for this project + this.stopAutoLoopForProject(projectPath); + } + + /** + * Signal that we should pause due to repeated failures or quota exhaustion (legacy global). */ private signalShouldPause(errorInfo: { type: string; message: string }): void { if (this.pausedDueToFailures) { @@ -341,7 +435,19 @@ export class AutoModeService { } /** - * Reset failure tracking (called when user manually restarts auto mode) + * Reset failure tracking for a specific project + * @param projectPath - The project to reset failure tracking for + */ + private resetFailureTrackingForProject(projectPath: string): void { + const projectState = this.autoLoopsByProject.get(projectPath); + if (projectState) { + projectState.consecutiveFailures = []; + projectState.pausedDueToFailures = false; + } + } + + /** + * Reset failure tracking (called when user manually restarts auto mode) - legacy global */ private resetFailureTracking(): void { this.consecutiveFailures = []; @@ -349,16 +455,255 @@ export class AutoModeService { } /** - * Record a successful feature completion to reset consecutive failure count + * Record a successful feature completion to reset consecutive failure count for a project + * @param projectPath - The project to record success for + */ + private recordSuccessForProject(projectPath: string): void { + const projectState = this.autoLoopsByProject.get(projectPath); + if (projectState) { + projectState.consecutiveFailures = []; + } + } + + /** + * Record a successful feature completion to reset consecutive failure count - legacy global */ private recordSuccess(): void { this.consecutiveFailures = []; } + /** + * Start the auto mode loop for a specific project (supports multiple concurrent projects) + * @param projectPath - The project to start auto mode for + * @param maxConcurrency - Maximum concurrent features (default: 3) + */ + async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise { + // Check if this project already has an active autoloop + const existingState = this.autoLoopsByProject.get(projectPath); + if (existingState?.isRunning) { + throw new Error(`Auto mode is already running for project: ${projectPath}`); + } + + // Create new project autoloop state + const abortController = new AbortController(); + const config: AutoModeConfig = { + maxConcurrency, + useWorktrees: true, + projectPath, + }; + + const projectState: ProjectAutoLoopState = { + abortController, + config, + isRunning: true, + consecutiveFailures: [], + pausedDueToFailures: false, + }; + + this.autoLoopsByProject.set(projectPath, projectState); + + logger.info( + `Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}` + ); + + this.emitAutoModeEvent('auto_mode_started', { + message: `Auto mode started with max ${maxConcurrency} concurrent features`, + projectPath, + }); + + // Save execution state for recovery after restart + await this.saveExecutionStateForProject(projectPath, maxConcurrency); + + // Run the loop in the background + this.runAutoLoopForProject(projectPath).catch((error) => { + logger.error(`Loop error for ${projectPath}:`, error); + const errorInfo = classifyError(error); + this.emitAutoModeEvent('auto_mode_error', { + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + }); + } + + /** + * Run the auto loop for a specific project + */ + private async runAutoLoopForProject(projectPath: string): Promise { + const projectState = this.autoLoopsByProject.get(projectPath); + if (!projectState) { + logger.warn(`No project state found for ${projectPath}, stopping loop`); + return; + } + + logger.info( + `[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` + ); + let iterationCount = 0; + + while (projectState.isRunning && !projectState.abortController.signal.aborted) { + iterationCount++; + try { + // Count running features for THIS project only + const projectRunningCount = this.getRunningCountForProject(projectPath); + + // Check if we have capacity for this project + if (projectRunningCount >= projectState.config.maxConcurrency) { + logger.debug( + `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` + ); + await this.sleep(5000); + continue; + } + + // Load pending features for this project + const pendingFeatures = await this.loadPendingFeatures(projectPath); + + logger.debug( + `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running` + ); + + if (pendingFeatures.length === 0) { + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + }); + logger.info(`[AutoLoop] No pending features, sleeping for 10s...`); + await this.sleep(10000); + continue; + } + + // Find a feature not currently running + const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); + + if (nextFeature) { + logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); + // Start feature execution in background + this.executeFeature( + projectPath, + nextFeature.id, + projectState.config.useWorktrees, + true + ).catch((error) => { + logger.error(`Feature ${nextFeature.id} error:`, error); + }); + } else { + logger.debug(`[AutoLoop] All pending features are already running`); + } + + await this.sleep(2000); + } catch (error) { + logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error); + await this.sleep(5000); + } + } + + // Mark as not running when loop exits + projectState.isRunning = false; + logger.info( + `[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations` + ); + } + + /** + * Get count of running features for a specific project + */ + private getRunningCountForProject(projectPath: string): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath === projectPath) { + count++; + } + } + return count; + } + + /** + * Stop the auto mode loop for a specific project + * @param projectPath - The project to stop auto mode for + */ + async stopAutoLoopForProject(projectPath: string): Promise { + const projectState = this.autoLoopsByProject.get(projectPath); + if (!projectState) { + logger.warn(`No auto loop running for project: ${projectPath}`); + return 0; + } + + const wasRunning = projectState.isRunning; + projectState.isRunning = false; + projectState.abortController.abort(); + + // Clear execution state when auto-loop is explicitly stopped + await this.clearExecutionState(projectPath); + + // Emit stop event + if (wasRunning) { + this.emitAutoModeEvent('auto_mode_stopped', { + message: 'Auto mode stopped', + projectPath, + }); + } + + // Remove from map + this.autoLoopsByProject.delete(projectPath); + + return this.getRunningCountForProject(projectPath); + } + + /** + * Check if auto mode is running for a specific project + */ + isAutoLoopRunningForProject(projectPath: string): boolean { + const projectState = this.autoLoopsByProject.get(projectPath); + return projectState?.isRunning ?? false; + } + + /** + * Get auto loop config for a specific project + */ + getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null { + const projectState = this.autoLoopsByProject.get(projectPath); + return projectState?.config ?? null; + } + + /** + * Save execution state for a specific project + */ + private async saveExecutionStateForProject( + projectPath: string, + maxConcurrency: number + ): Promise { + try { + await ensureAutomakerDir(projectPath); + const statePath = getExecutionStatePath(projectPath); + const runningFeatureIds = Array.from(this.runningFeatures.entries()) + .filter(([, f]) => f.projectPath === projectPath) + .map(([id]) => id); + + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency, + projectPath, + runningFeatureIds, + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + logger.info( + `Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features` + ); + } catch (error) { + logger.error(`Failed to save execution state for ${projectPath}:`, error); + } + } + /** * Start the auto mode loop - continuously picks and executes pending features + * @deprecated Use startAutoLoopForProject instead for multi-project support */ async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { + // For backward compatibility, delegate to the new per-project method + // But also maintain legacy state for existing code that might check it if (this.autoLoopRunning) { throw new Error('Auto mode is already running'); } @@ -396,6 +741,9 @@ export class AutoModeService { }); } + /** + * @deprecated Use runAutoLoopForProject instead + */ private async runAutoLoop(): Promise { while ( this.autoLoopRunning && @@ -448,6 +796,7 @@ export class AutoModeService { /** * Stop the auto mode loop + * @deprecated Use stopAutoLoopForProject instead for multi-project support */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; @@ -1777,6 +2126,46 @@ Format your response as a structured markdown document.`; }; } + /** + * Get status for a specific project + * @param projectPath - The project to get status for + */ + getStatusForProject(projectPath: string): { + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + } { + const projectState = this.autoLoopsByProject.get(projectPath); + const runningFeatures: string[] = []; + + for (const [featureId, feature] of this.runningFeatures) { + if (feature.projectPath === projectPath) { + runningFeatures.push(featureId); + } + } + + return { + isAutoLoopRunning: projectState?.isRunning ?? false, + runningFeatures, + runningCount: runningFeatures.length, + maxConcurrency: projectState?.config.maxConcurrency ?? 3, + }; + } + + /** + * Get all projects that have auto mode running + */ + getActiveAutoLoopProjects(): string[] { + const activeProjects: string[] = []; + for (const [projectPath, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeProjects.push(projectPath); + } + } + return activeProjects; + } + /** * Get detailed info about all running agents */ @@ -2254,6 +2643,10 @@ Format your response as a structured markdown document.`; } } + logger.debug( + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status` + ); + // Apply dependency-aware ordering const { orderedFeatures } = resolveDependencies(pendingFeatures); @@ -2266,8 +2659,13 @@ Format your response as a structured markdown document.`; areDependenciesSatisfied(feature, allFeatures, { skipVerification }) ); + logger.debug( + `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` + ); + return readyFeatures; - } catch { + } catch (error) { + logger.error(`[loadPendingFeatures] Error loading features:`, error); return []; } } diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6400b13bc..5b9f81cbc 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -41,6 +41,7 @@ import { CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, } from '../types/settings.js'; +import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types'; const logger = createLogger('SettingsService'); @@ -127,10 +128,14 @@ export class SettingsService { // Migrate legacy enhancementModel/validationModel to phaseModels const migratedPhaseModels = this.migratePhaseModels(settings); + // Migrate model IDs to canonical format + const migratedModelSettings = this.migrateModelSettings(settings); + // Apply any missing defaults (for backwards compatibility) let result: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...settings, + ...migratedModelSettings, keyboardShortcuts: { ...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, ...settings.keyboardShortcuts, @@ -226,19 +231,70 @@ export class SettingsService { * Convert a phase model value to PhaseModelEntry format * * Handles migration from string format (v2) to object format (v3). - * - String values like 'sonnet' become { model: 'sonnet' } - * - Object values are returned as-is (with type assertion) + * Also migrates legacy model IDs to canonical prefixed format. + * - String values like 'sonnet' become { model: 'claude-sonnet' } + * - Object values have their model ID migrated if needed * * @param value - Phase model value (string or PhaseModelEntry) - * @returns PhaseModelEntry object + * @returns PhaseModelEntry object with canonical model ID */ private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry { if (typeof value === 'string') { - // v2 format: just a model string - return { model: value as PhaseModelEntry['model'] }; + // v2 format: just a model string - migrate to canonical ID + return { model: migrateModelId(value) as PhaseModelEntry['model'] }; + } + // v3 format: PhaseModelEntry object - migrate model ID if needed + return { + ...value, + model: migrateModelId(value.model) as PhaseModelEntry['model'], + }; + } + + /** + * Migrate model-related settings to canonical format + * + * Migrates: + * - enabledCursorModels: legacy IDs to cursor- prefixed + * - enabledOpencodeModels: legacy slash format to dash format + * - cursorDefaultModel: legacy ID to cursor- prefixed + * + * @param settings - Settings to migrate + * @returns Settings with migrated model IDs + */ + private migrateModelSettings(settings: Partial): Partial { + const migrated: Partial = { ...settings }; + + // Migrate Cursor models + if (settings.enabledCursorModels) { + migrated.enabledCursorModels = migrateCursorModelIds( + settings.enabledCursorModels as string[] + ); + } + + // Migrate Cursor default model + if (settings.cursorDefaultModel) { + const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]); + if (migratedDefault.length > 0) { + migrated.cursorDefaultModel = migratedDefault[0]; + } } - // v3 format: already a PhaseModelEntry object - return value; + + // Migrate OpenCode models + if (settings.enabledOpencodeModels) { + migrated.enabledOpencodeModels = migrateOpencodeModelIds( + settings.enabledOpencodeModels as string[] + ); + } + + // Migrate OpenCode default model + if (settings.opencodeDefaultModel) { + const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]); + if (migratedDefault.length > 0) { + migrated.opencodeDefaultModel = migratedDefault[0]; + } + } + + return migrated; } /** diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c2ea61234..8773180d0 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -37,7 +37,7 @@ describe('model-resolver.ts', () => { const result = resolveModelString('opus'); expect(result).toBe('claude-opus-4-5-20251101'); expect(consoleSpy.log).toHaveBeenCalledWith( - expect.stringContaining('Resolved Claude model alias: "opus"') + expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); }); diff --git a/apps/server/tests/unit/providers/cursor-config-manager.test.ts b/apps/server/tests/unit/providers/cursor-config-manager.test.ts index 133daabad..114854098 100644 --- a/apps/server/tests/unit/providers/cursor-config-manager.test.ts +++ b/apps/server/tests/unit/providers/cursor-config-manager.test.ts @@ -50,8 +50,8 @@ describe('cursor-config-manager.ts', () => { manager = new CursorConfigManager(testProjectPath); const config = manager.getConfig(); - expect(config.defaultModel).toBe('auto'); - expect(config.models).toContain('auto'); + expect(config.defaultModel).toBe('cursor-auto'); + expect(config.models).toContain('cursor-auto'); }); it('should use default config if file read fails', () => { @@ -62,7 +62,7 @@ describe('cursor-config-manager.ts', () => { manager = new CursorConfigManager(testProjectPath); - expect(manager.getDefaultModel()).toBe('auto'); + expect(manager.getDefaultModel()).toBe('cursor-auto'); }); it('should use default config if JSON parse fails', () => { @@ -71,7 +71,7 @@ describe('cursor-config-manager.ts', () => { manager = new CursorConfigManager(testProjectPath); - expect(manager.getDefaultModel()).toBe('auto'); + expect(manager.getDefaultModel()).toBe('cursor-auto'); }); }); @@ -93,7 +93,7 @@ describe('cursor-config-manager.ts', () => { }); it('should return default model', () => { - expect(manager.getDefaultModel()).toBe('auto'); + expect(manager.getDefaultModel()).toBe('cursor-auto'); }); it('should set and persist default model', () => { @@ -103,13 +103,13 @@ describe('cursor-config-manager.ts', () => { expect(fs.writeFileSync).toHaveBeenCalled(); }); - it('should return auto if defaultModel is undefined', () => { + it('should return cursor-auto if defaultModel is undefined', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['auto'] })); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] })); manager = new CursorConfigManager(testProjectPath); - expect(manager.getDefaultModel()).toBe('auto'); + expect(manager.getDefaultModel()).toBe('cursor-auto'); }); }); @@ -121,7 +121,7 @@ describe('cursor-config-manager.ts', () => { it('should return enabled models', () => { const models = manager.getEnabledModels(); expect(Array.isArray(models)).toBe(true); - expect(models).toContain('auto'); + expect(models).toContain('cursor-auto'); }); it('should set enabled models', () => { @@ -131,13 +131,13 @@ describe('cursor-config-manager.ts', () => { expect(fs.writeFileSync).toHaveBeenCalled(); }); - it('should return [auto] if models is undefined', () => { + it('should return [cursor-auto] if models is undefined', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' })); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' })); manager = new CursorConfigManager(testProjectPath); - expect(manager.getEnabledModels()).toEqual(['auto']); + expect(manager.getEnabledModels()).toEqual(['cursor-auto']); }); }); @@ -146,8 +146,8 @@ describe('cursor-config-manager.ts', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue( JSON.stringify({ - defaultModel: 'auto', - models: ['auto'], + defaultModel: 'cursor-auto', + models: ['cursor-auto'], }) ); manager = new CursorConfigManager(testProjectPath); @@ -161,14 +161,14 @@ describe('cursor-config-manager.ts', () => { }); it('should not add duplicate models', () => { - manager.addModel('auto'); + manager.addModel('cursor-auto'); // Should not save if model already exists expect(fs.writeFileSync).not.toHaveBeenCalled(); }); it('should initialize models array if undefined', () => { - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' })); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' })); manager = new CursorConfigManager(testProjectPath); manager.addModel('claude-3-5-sonnet'); @@ -293,7 +293,7 @@ describe('cursor-config-manager.ts', () => { it('should reset to default values', () => { manager.reset(); - expect(manager.getDefaultModel()).toBe('auto'); + expect(manager.getDefaultModel()).toBe('cursor-auto'); expect(manager.getMcpServers()).toEqual([]); expect(manager.getRules()).toEqual([]); expect(fs.writeFileSync).toHaveBeenCalled(); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index 3a0c6d774..70511af86 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -647,9 +647,10 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); // Verify all phase models are now PhaseModelEntry objects - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); - expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' }); - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + // Legacy aliases are migrated to canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); expect(settings.version).toBe(SETTINGS_VERSION); }); @@ -675,16 +676,17 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); // Verify PhaseModelEntry objects are preserved with thinkingLevel + // Legacy aliases are migrated to canonical IDs expect(settings.phaseModels.enhancementModel).toEqual({ - model: 'sonnet', + model: 'claude-sonnet', thinkingLevel: 'high', }); expect(settings.phaseModels.specGenerationModel).toEqual({ - model: 'opus', + model: 'claude-opus', thinkingLevel: 'ultrathink', }); expect(settings.phaseModels.backlogPlanningModel).toEqual({ - model: 'sonnet', + model: 'claude-sonnet', thinkingLevel: 'medium', }); }); @@ -710,15 +712,15 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Strings should be converted to objects - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); - expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' }); - // Objects should be preserved + // Strings should be converted to objects with canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' }); + // Objects should be preserved with migrated IDs expect(settings.phaseModels.fileDescriptionModel).toEqual({ - model: 'haiku', + model: 'claude-haiku', thinkingLevel: 'low', }); - expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); }); it('should migrate legacy enhancementModel/validationModel fields', async () => { @@ -735,11 +737,11 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Legacy fields should be migrated to phaseModels - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' }); - expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' }); - // Other fields should use defaults - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + // Legacy fields should be migrated to phaseModels with canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); + // Other fields should use defaults (canonical IDs) + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); }); it('should use default phase models when none are configured', async () => { @@ -753,10 +755,10 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Should use DEFAULT_PHASE_MODELS - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' }); - expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' }); - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' }); + // Should use DEFAULT_PHASE_MODELS (with canonical IDs) + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); }); it('should deep merge phaseModels on update', async () => { @@ -776,13 +778,13 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Both should be preserved + // Both should be preserved (models migrated to canonical format) expect(settings.phaseModels.enhancementModel).toEqual({ - model: 'sonnet', + model: 'claude-sonnet', thinkingLevel: 'high', }); expect(settings.phaseModels.specGenerationModel).toEqual({ - model: 'opus', + model: 'claude-opus', thinkingLevel: 'ultrathink', }); }); diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index a62254c73..5f0b66338 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { if (modelStr.includes('grok')) { return 'grok'; } - if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') { + // Cursor models - canonical format includes 'cursor-' prefix + // Also support legacy IDs for backward compatibility + if ( + modelStr.includes('cursor') || + modelStr === 'auto' || + modelStr === 'composer-1' || + modelStr === 'cursor-auto' || + modelStr === 'cursor-composer-1' + ) { return 'cursor'; } diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 5d8774717..1278601c2 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -42,7 +42,7 @@ export function AgentView() { return () => window.removeEventListener('resize', updateVisibility); }, []); - const [modelSelection, setModelSelection] = useState({ model: 'sonnet' }); + const [modelSelection, setModelSelection] = useState({ model: 'claude-sonnet' }); // Input ref for auto-focus const inputRef = useRef(null); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index bc2f5a37c..0aa804626 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -856,68 +856,9 @@ export function BoardView() { [handleAddFeature, handleStartImplementation] ); - // Client-side auto mode: periodically check for backlog items and move them to in-progress - // Use a ref to track the latest auto mode state so async operations always check the current value - const autoModeRunningRef = useRef(autoMode.isRunning); - useEffect(() => { - autoModeRunningRef.current = autoMode.isRunning; - }, [autoMode.isRunning]); - - // Use a ref to track the latest features to avoid effect re-runs when features change - const hookFeaturesRef = useRef(hookFeatures); - useEffect(() => { - hookFeaturesRef.current = hookFeatures; - }, [hookFeatures]); - - // Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef - const runningAutoTasksRef = useRef(runningAutoTasks); - useEffect(() => { - runningAutoTasksRef.current = runningAutoTasks; - }, [runningAutoTasks]); - - // Keep latest start handler without retriggering the auto mode effect - const handleStartImplementationRef = useRef(handleStartImplementation); - useEffect(() => { - handleStartImplementationRef.current = handleStartImplementation; - }, [handleStartImplementation]); - - // Track features that are pending (started but not yet confirmed running) - const pendingFeaturesRef = useRef>(new Set()); - - // Listen to auto mode events to remove features from pending when they start running - useEffect(() => { - const api = getElectronAPI(); - if (!api?.autoMode) return; - - const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { - if (!currentProject) return; - - // Only process events for the current project - const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined; - if (eventProjectPath && eventProjectPath !== currentProject.path) { - return; - } - - switch (event.type) { - case 'auto_mode_feature_start': - // Feature is now confirmed running - remove from pending - if (event.featureId) { - pendingFeaturesRef.current.delete(event.featureId); - } - break; - - case 'auto_mode_feature_complete': - case 'auto_mode_error': - // Feature completed or errored - remove from pending if still there - if (event.featureId) { - pendingFeaturesRef.current.delete(event.featureId); - } - break; - } - }); - - return unsubscribe; - }, [currentProject]); + // NOTE: Auto mode polling loop has been moved to the backend. + // The frontend now just toggles the backend's auto loop via API calls. + // See use-auto-mode.ts for the start/stop logic that calls the backend. // Listen for backlog plan events (for background generation) useEffect(() => { @@ -976,219 +917,6 @@ export function BoardView() { }; }, [currentProject, pendingBacklogPlan]); - useEffect(() => { - logger.info( - '[AutoMode] Effect triggered - isRunning:', - autoMode.isRunning, - 'hasProject:', - !!currentProject - ); - if (!autoMode.isRunning || !currentProject) { - return; - } - - logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path); - let isChecking = false; - let isActive = true; // Track if this effect is still active - - const checkAndStartFeatures = async () => { - // Check if auto mode is still running and effect is still active - // Use ref to get the latest value, not the closure value - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Prevent concurrent executions - if (isChecking) { - return; - } - - isChecking = true; - try { - // Double-check auto mode is still running before proceeding - if (!isActive || !autoModeRunningRef.current || !currentProject) { - logger.debug( - '[AutoMode] Skipping check - isActive:', - isActive, - 'autoModeRunning:', - autoModeRunningRef.current, - 'hasProject:', - !!currentProject - ); - return; - } - - // Count currently running tasks + pending features - // Use ref to get the latest running tasks without causing effect re-runs - const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; - const availableSlots = maxConcurrency - currentRunning; - logger.debug( - '[AutoMode] Checking features - running:', - currentRunning, - 'available slots:', - availableSlots - ); - - // No available slots, skip check - if (availableSlots <= 0) { - return; - } - - // Filter backlog features by the currently selected worktree branch - // This logic mirrors use-board-column-features.ts for consistency. - // HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree, - // so we fall back to "all backlog features" when none are visible in the current view. - // Use ref to get the latest features without causing effect re-runs - const currentFeatures = hookFeaturesRef.current; - const backlogFeaturesInView = currentFeatures.filter((f) => { - if (f.status !== 'backlog') return false; - - const featureBranch = f.branchName; - - // Features without branchName are considered unassigned (show only on primary worktree) - if (!featureBranch) { - // No branch assigned - show only when viewing primary worktree - const isViewingPrimary = currentWorktreePath === null; - return isViewingPrimary; - } - - if (currentWorktreeBranch === null) { - // We're viewing main but branch hasn't been initialized yet - // Show features assigned to primary worktree's branch - return currentProject.path - ? isPrimaryWorktreeBranch(currentProject.path, featureBranch) - : false; - } - - // Match by branch name - return featureBranch === currentWorktreeBranch; - }); - - const backlogFeatures = - backlogFeaturesInView.length > 0 - ? backlogFeaturesInView - : currentFeatures.filter((f) => f.status === 'backlog'); - - logger.debug( - '[AutoMode] Features - total:', - currentFeatures.length, - 'backlog in view:', - backlogFeaturesInView.length, - 'backlog total:', - backlogFeatures.length - ); - - if (backlogFeatures.length === 0) { - logger.debug( - '[AutoMode] No backlog features found, statuses:', - currentFeatures.map((f) => f.status).join(', ') - ); - return; - } - - // Sort by priority (lower number = higher priority, priority 1 is highest) - const sortedBacklog = [...backlogFeatures].sort( - (a, b) => (a.priority || 999) - (b.priority || 999) - ); - - // Filter out features with blocking dependencies if dependency blocking is enabled - // NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we - // should NOT exclude blocked features in that mode. - const eligibleFeatures = - enableDependencyBlocking && !skipVerificationInAutoMode - ? sortedBacklog.filter((f) => { - const blockingDeps = getBlockingDependencies(f, currentFeatures); - if (blockingDeps.length > 0) { - logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps); - } - return blockingDeps.length === 0; - }) - : sortedBacklog; - - logger.debug( - '[AutoMode] Eligible features after dep check:', - eligibleFeatures.length, - 'dependency blocking enabled:', - enableDependencyBlocking - ); - - // Start features up to available slots - const featuresToStart = eligibleFeatures.slice(0, availableSlots); - const startImplementation = handleStartImplementationRef.current; - if (!startImplementation) { - return; - } - - logger.info( - '[AutoMode] Starting', - featuresToStart.length, - 'features:', - featuresToStart.map((f) => f.id).join(', ') - ); - - for (const feature of featuresToStart) { - // Check again before starting each feature - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Simplified: No worktree creation on client - server derives workDir from feature.branchName - // If feature has no branchName, assign it to the primary branch so it can run consistently - // even when the user is viewing a non-primary worktree. - if (!feature.branchName) { - const primaryBranch = - (currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || - 'main'; - await persistFeatureUpdate(feature.id, { - branchName: primaryBranch, - }); - } - - // Final check before starting implementation - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Start the implementation - server will derive workDir from feature.branchName - const started = await startImplementation(feature); - - // If successfully started, track it as pending until we receive the start event - if (started) { - pendingFeaturesRef.current.add(feature.id); - } - } - } finally { - isChecking = false; - } - }; - - // Check immediately, then every 3 seconds - checkAndStartFeatures(); - const interval = setInterval(checkAndStartFeatures, 3000); - - return () => { - // Mark as inactive to prevent any pending async operations from continuing - isActive = false; - clearInterval(interval); - // Clear pending features when effect unmounts or dependencies change - pendingFeaturesRef.current.clear(); - }; - }, [ - autoMode.isRunning, - currentProject, - // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs - // that would clear pendingFeaturesRef and cause concurrency issues - maxConcurrency, - // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs - currentWorktreeBranch, - currentWorktreePath, - getPrimaryWorktreeBranch, - isPrimaryWorktreeBranch, - enableDependencyBlocking, - skipVerificationInAutoMode, - persistFeatureUpdate, - ]); - // Use keyboard shortcuts hook (after actions hook) useBoardKeyboardShortcuts({ features: hookFeatures, @@ -1403,9 +1131,13 @@ export function BoardView() { isAutoModeRunning={autoMode.isRunning} onAutoModeToggle={(enabled) => { if (enabled) { - autoMode.start(); + autoMode.start().catch((error) => { + logger.error('[AutoMode] Failed to start:', error); + }); } else { - autoMode.stop(); + autoMode.stop().catch((error) => { + logger.error('[AutoMode] Failed to stop:', error); + }); } }} onOpenPlanDialog={() => setShowPlanDialog(true)} diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 590d7789b..e4ba03d4b 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -170,7 +170,7 @@ export function AddFeatureDialog({ const [priority, setPriority] = useState(2); // Model selection state - const [modelEntry, setModelEntry] = useState({ model: 'opus' }); + const [modelEntry, setModelEntry] = useState({ model: 'claude-opus' }); // Check if current model supports planning mode (Claude/Anthropic only) const modelSupportsPlanningMode = isClaudeModel(modelEntry.model); diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index c04d4b340..1a5c187dd 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -28,6 +28,7 @@ import { toast } from 'sonner'; import { cn, modelSupportsThinking } from '@/lib/utils'; import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store'; import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types'; +import { migrateModelId } from '@automaker/types'; import { TestingTabContent, PrioritySelector, @@ -107,9 +108,9 @@ export function EditFeatureDialog({ feature?.requirePlanApproval ?? false ); - // Model selection state + // Model selection state - migrate legacy model IDs to canonical format const [modelEntry, setModelEntry] = useState(() => ({ - model: (feature?.model as ModelAlias) || 'opus', + model: migrateModelId(feature?.model) || 'claude-opus', thinkingLevel: feature?.thinkingLevel || 'none', reasoningEffort: feature?.reasoningEffort || 'none', })); @@ -157,9 +158,9 @@ export function EditFeatureDialog({ setDescriptionChangeSource(null); setPreEnhancementDescription(null); setLocalHistory(feature.descriptionHistory ?? []); - // Reset model entry + // Reset model entry - migrate legacy model IDs setModelEntry({ - model: (feature.model as ModelAlias) || 'opus', + model: migrateModelId(feature.model) || 'claude-opus', thinkingLevel: feature.thinkingLevel || 'none', reasoningEffort: feature.reasoningEffort || 'none', }); diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx index 2be7d32f1..f98908f97 100644 --- a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -126,7 +126,7 @@ export function MassEditDialog({ }); // Field values - const [model, setModel] = useState('sonnet'); + const [model, setModel] = useState('claude-sonnet'); const [thinkingLevel, setThinkingLevel] = useState('none'); const [planningMode, setPlanningMode] = useState('skip'); const [requirePlanApproval, setRequirePlanApproval] = useState(false); @@ -160,7 +160,7 @@ export function MassEditDialog({ skipTests: false, branchName: false, }); - setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias); + setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias); setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode); setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false)); diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index d871ab302..33bd624a6 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; export type ModelOption = { - id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}" + id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto") label: string; description: string; badge?: string; @@ -17,23 +17,27 @@ export type ModelOption = { hasThinking?: boolean; }; +/** + * Claude models with canonical prefixed IDs + * UI displays short labels but stores full canonical IDs + */ export const CLAUDE_MODELS: ModelOption[] = [ { - id: 'haiku', + id: 'claude-haiku', // Canonical prefixed ID label: 'Claude Haiku', description: 'Fast and efficient for simple tasks.', badge: 'Speed', provider: 'claude', }, { - id: 'sonnet', + id: 'claude-sonnet', // Canonical prefixed ID label: 'Claude Sonnet', description: 'Balanced performance with strong reasoning.', badge: 'Balanced', provider: 'claude', }, { - id: 'opus', + id: 'claude-opus', // Canonical prefixed ID label: 'Claude Opus', description: 'Most capable model for complex work.', badge: 'Premium', @@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [ /** * Cursor models derived from CURSOR_MODEL_MAP - * ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed) + * IDs already have 'cursor-' prefix in the canonical format */ export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map( ([id, config]) => ({ - id: id.startsWith('cursor-') ? id : `cursor-${id}`, + id, // Already prefixed in canonical format label: config.label, description: config.description, provider: 'cursor' as ModelProvider, diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index fb6deeaef..79a8c2279 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -70,22 +70,30 @@ export function ModelSelector({ // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { - // Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix - return enabledCursorModels.includes(model.id as any); + // enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix + // (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix) + // CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts + // Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models) + const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id; + return ( + enabledCursorModels.includes(model.id as any) || + enabledCursorModels.includes(unprefixedId as any) + ); }); const handleProviderChange = (provider: ModelProvider) => { if (provider === 'cursor' && selectedProvider !== 'cursor') { // Switch to Cursor's default model (from global settings) - onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); + // cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly + onModelSelect(cursorDefaultModel); } else if (provider === 'codex' && selectedProvider !== 'codex') { // Switch to Codex's default model (use isDefault flag from dynamic models) const defaultModel = codexModels.find((m) => m.isDefault); const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex'; onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { - // Switch to Claude's default model - onModelSelect('sonnet'); + // Switch to Claude's default model (canonical format) + onModelSelect('claude-sonnet'); } }; diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 392445e0d..69392afac 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -279,8 +279,8 @@ export function PhaseModelSelector({ }, [codexModels]); // Filter Cursor models to only show enabled ones + // With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format const availableCursorModels = CURSOR_MODELS.filter((model) => { - // Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix return enabledCursorModels.includes(model.id as CursorModelId); }); @@ -300,6 +300,7 @@ export function PhaseModelSelector({ }; } + // With canonical IDs, direct comparison works const cursorModel = availableCursorModels.find((m) => m.id === selectedModel); if (cursorModel) return { ...cursorModel, icon: CursorIcon }; @@ -352,7 +353,7 @@ export function PhaseModelSelector({ const seenGroups = new Set(); availableCursorModels.forEach((model) => { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; + const cursorId = model.id as CursorModelId; // Check if this model is standalone if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { @@ -908,8 +909,8 @@ export function PhaseModelSelector({ // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { - const modelValue = stripProviderPrefix(model.id); - const isSelected = selectedModel === modelValue; + // With canonical IDs, store the full prefixed ID + const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); return ( @@ -917,7 +918,7 @@ export function PhaseModelSelector({ key={model.id} value={model.label} onSelect={() => { - onChange({ model: modelValue as CursorModelId }); + onChange({ model: model.id as CursorModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" @@ -1458,7 +1459,7 @@ export function PhaseModelSelector({ return favorites.map((model) => { // Check if this favorite is part of a grouped model if (model.provider === 'cursor') { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; + const cursorId = model.id as CursorModelId; const group = getModelGroup(cursorId); if (group) { // Skip if we already rendered this group diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx index 99a27be4b..6e3f7097e 100644 --- a/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx @@ -92,7 +92,8 @@ export function CursorModelConfiguration({
{availableModels.map((model) => { const isEnabled = enabledCursorModels.includes(model.id); - const isAuto = model.id === 'auto'; + // With canonical IDs, 'auto' becomes 'cursor-auto' + const isAuto = model.id === 'cursor-auto'; return (
{ if (!currentProject) return; - const session = readAutoModeSession(); - const desired = session[currentProject.path]; - if (typeof desired !== 'boolean') return; + const syncWithBackend = async () => { + try { + const api = getElectronAPI(); + if (!api?.autoMode?.status) return; + + const result = await api.autoMode.status(currentProject.path); + if (result.success && result.isAutoLoopRunning !== undefined) { + const backendIsRunning = result.isAutoLoopRunning; + if (backendIsRunning !== isAutoModeRunning) { + logger.info( + `[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` + ); + setAutoModeRunning(currentProject.id, backendIsRunning); + setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning); + } + } + } catch (error) { + logger.error('Error syncing auto mode state with backend:', error); + } + }; - if (desired !== isAutoModeRunning) { - logger.info( - `[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}` - ); - setAutoModeRunning(currentProject.id, desired); - } + syncWithBackend(); }, [currentProject, isAutoModeRunning, setAutoModeRunning]); // Handle auto mode events - listen globally for all projects @@ -139,6 +151,22 @@ export function useAutoMode() { } switch (event.type) { + case 'auto_mode_started': + // Backend started auto loop - update UI state + logger.info('[AutoMode] Backend started auto loop for project'); + if (eventProjectId) { + setAutoModeRunning(eventProjectId, true); + } + break; + + case 'auto_mode_stopped': + // Backend stopped auto loop - update UI state + logger.info('[AutoMode] Backend stopped auto loop for project'); + if (eventProjectId) { + setAutoModeRunning(eventProjectId, false); + } + break; + case 'auto_mode_feature_start': if (event.featureId) { addRunningTask(eventProjectId, event.featureId); @@ -374,35 +402,92 @@ export function useAutoMode() { addAutoModeActivity, getProjectIdFromPath, setPendingPlanApproval, + setAutoModeRunning, currentProject?.path, ]); - // Start auto mode - UI only, feature pickup is handled in board-view.tsx - const start = useCallback(() => { + // Start auto mode - calls backend to start the auto loop + const start = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); return; } - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); - logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.start) { + throw new Error('Start auto mode API not available'); + } + + logger.info( + `[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}` + ); + + // Optimistically update UI state (backend will confirm via event) + setAutoModeSessionForProjectPath(currentProject.path, true); + setAutoModeRunning(currentProject.id, true); + + // Call backend to start the auto loop + const result = await api.autoMode.start(currentProject.path, maxConcurrency); + + if (!result.success) { + // Revert UI state on failure + setAutoModeSessionForProjectPath(currentProject.path, false); + setAutoModeRunning(currentProject.id, false); + logger.error('Failed to start auto mode:', result.error); + throw new Error(result.error || 'Failed to start auto mode'); + } + + logger.debug(`[AutoMode] Started successfully`); + } catch (error) { + // Revert UI state on error + setAutoModeSessionForProjectPath(currentProject.path, false); + setAutoModeRunning(currentProject.id, false); + logger.error('Error starting auto mode:', error); + throw error; + } }, [currentProject, setAutoModeRunning, maxConcurrency]); - // Stop auto mode - UI only, running tasks continue until natural completion - const stop = useCallback(() => { + // Stop auto mode - calls backend to stop the auto loop + const stop = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); return; } - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); - // NOTE: We intentionally do NOT clear running tasks here. - // Stopping auto mode only turns off the toggle to prevent new features - // from being picked up. Running tasks will complete naturally and be - // removed via the auto_mode_feature_complete event. - logger.info('Stopped - running tasks will continue'); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.stop) { + throw new Error('Stop auto mode API not available'); + } + + logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`); + + // Optimistically update UI state (backend will confirm via event) + setAutoModeSessionForProjectPath(currentProject.path, false); + setAutoModeRunning(currentProject.id, false); + + // Call backend to stop the auto loop + const result = await api.autoMode.stop(currentProject.path); + + if (!result.success) { + // Revert UI state on failure + setAutoModeSessionForProjectPath(currentProject.path, true); + setAutoModeRunning(currentProject.id, true); + logger.error('Failed to stop auto mode:', result.error); + throw new Error(result.error || 'Failed to stop auto mode'); + } + + // NOTE: Running tasks will continue until natural completion. + // The backend stops picking up new features but doesn't abort running ones. + logger.info('Stopped - running tasks will continue'); + } catch (error) { + // Revert UI state on error + setAutoModeSessionForProjectPath(currentProject.path, true); + setAutoModeRunning(currentProject.id, true); + logger.error('Error stopping auto mode:', error); + throw error; + } }, [currentProject, setAutoModeRunning]); // Stop a specific feature diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 63e62c50e..58b3ec2da 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -31,7 +31,11 @@ import { useSetupStore } from '@/store/setup-store'; import { DEFAULT_OPENCODE_MODEL, getAllOpencodeModelIds, + getAllCursorModelIds, + migrateCursorModelIds, + migratePhaseModelEntry, type GlobalSettings, + type CursorModelId, } from '@automaker/types'; const logger = createLogger('SettingsMigration'); @@ -566,6 +570,19 @@ export function useSettingsMigration(): MigrationState { */ export function hydrateStoreFromSettings(settings: GlobalSettings): void { const current = useAppStore.getState(); + + // Migrate Cursor models to canonical format + // IMPORTANT: Always use ALL available Cursor models to ensure new models are visible + // Users who had old settings with a subset of models should still see all available models + const allCursorModels = getAllCursorModelIds(); + const migratedCursorDefault = migrateCursorModelIds([ + settings.cursorDefaultModel ?? current.cursorDefaultModel ?? 'cursor-auto', + ])[0]; + const validCursorModelIds = new Set(allCursorModels); + const sanitizedCursorDefaultModel = validCursorModelIds.has(migratedCursorDefault) + ? migratedCursorDefault + : ('cursor-auto' as CursorModelId); + const validOpencodeModelIds = new Set(getAllOpencodeModelIds()); const incomingEnabledOpencodeModels = settings.enabledOpencodeModels ?? current.enabledOpencodeModels; @@ -631,15 +648,17 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { useWorktrees: settings.useWorktrees ?? true, defaultPlanningMode: settings.defaultPlanningMode ?? 'skip', defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false, - defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' }, + defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? { + model: 'claude-opus', + }, muteDoneSound: settings.muteDoneSound ?? false, serverLogLevel: settings.serverLogLevel ?? 'info', enableRequestLogging: settings.enableRequestLogging ?? true, - enhancementModel: settings.enhancementModel ?? 'sonnet', - validationModel: settings.validationModel ?? 'opus', + enhancementModel: settings.enhancementModel ?? 'claude-sonnet', + validationModel: settings.validationModel ?? 'claude-opus', phaseModels: settings.phaseModels ?? current.phaseModels, - enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels, - cursorDefaultModel: settings.cursorDefaultModel ?? 'auto', + enabledCursorModels: allCursorModels, // Always use ALL cursor models + cursorDefaultModel: sanitizedCursorDefaultModel, enabledOpencodeModels: sanitizedEnabledOpencodeModels, opencodeDefaultModel: sanitizedOpencodeDefaultModel, enabledDynamicModelIds: sanitizedDynamicModelIds, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 349c4ac71..87e888792 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -22,7 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi import { DEFAULT_OPENCODE_MODEL, getAllOpencodeModelIds, + getAllCursorModelIds, + migrateCursorModelIds, + migrateOpencodeModelIds, + migratePhaseModelEntry, type GlobalSettings, + type CursorModelId, + type OpencodeModelId, } from '@automaker/types'; const logger = createLogger('SettingsSync'); @@ -501,18 +507,36 @@ export async function refreshSettingsFromServer(): Promise { const serverSettings = result.settings as unknown as GlobalSettings; const currentAppState = useAppStore.getState(); + + // Cursor models - ALWAYS use ALL available models to ensure new models are visible + const allCursorModels = getAllCursorModelIds(); + const validCursorModelIds = new Set(allCursorModels); + + // Migrate Cursor default model + const migratedCursorDefault = migrateCursorModelIds([ + serverSettings.cursorDefaultModel ?? 'cursor-auto', + ])[0]; + const sanitizedCursorDefault = validCursorModelIds.has(migratedCursorDefault) + ? migratedCursorDefault + : ('cursor-auto' as CursorModelId); + + // Migrate OpenCode models to canonical format + const migratedOpencodeModels = migrateOpencodeModelIds( + serverSettings.enabledOpencodeModels ?? [] + ); const validOpencodeModelIds = new Set(getAllOpencodeModelIds()); - const incomingEnabledOpencodeModels = - serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels; - const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has( - serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel - ) - ? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel) - : DEFAULT_OPENCODE_MODEL; - const sanitizedEnabledOpencodeModels = Array.from( - new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId))) + const sanitizedEnabledOpencodeModels = migratedOpencodeModels.filter((id) => + validOpencodeModelIds.has(id) ); + // Migrate OpenCode default model + const migratedOpencodeDefault = migrateOpencodeModelIds([ + serverSettings.opencodeDefaultModel ?? DEFAULT_OPENCODE_MODEL, + ])[0]; + const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(migratedOpencodeDefault) + ? migratedOpencodeDefault + : DEFAULT_OPENCODE_MODEL; + if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) { sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel); } @@ -523,6 +547,37 @@ export async function refreshSettingsFromServer(): Promise { (modelId) => !modelId.startsWith('amazon-bedrock/') ); + // Migrate phase models to canonical format + const migratedPhaseModels = serverSettings.phaseModels + ? { + enhancementModel: migratePhaseModelEntry(serverSettings.phaseModels.enhancementModel), + fileDescriptionModel: migratePhaseModelEntry( + serverSettings.phaseModels.fileDescriptionModel + ), + imageDescriptionModel: migratePhaseModelEntry( + serverSettings.phaseModels.imageDescriptionModel + ), + validationModel: migratePhaseModelEntry(serverSettings.phaseModels.validationModel), + specGenerationModel: migratePhaseModelEntry( + serverSettings.phaseModels.specGenerationModel + ), + featureGenerationModel: migratePhaseModelEntry( + serverSettings.phaseModels.featureGenerationModel + ), + backlogPlanningModel: migratePhaseModelEntry( + serverSettings.phaseModels.backlogPlanningModel + ), + projectAnalysisModel: migratePhaseModelEntry( + serverSettings.phaseModels.projectAnalysisModel + ), + suggestionsModel: migratePhaseModelEntry(serverSettings.phaseModels.suggestionsModel), + memoryExtractionModel: migratePhaseModelEntry( + serverSettings.phaseModels.memoryExtractionModel + ), + commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel), + } + : undefined; + // Save theme to localStorage for fallback when server settings aren't available if (serverSettings.theme) { setItem(THEME_STORAGE_KEY, serverSettings.theme); @@ -539,15 +594,17 @@ export async function refreshSettingsFromServer(): Promise { useWorktrees: serverSettings.useWorktrees, defaultPlanningMode: serverSettings.defaultPlanningMode, defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval, - defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' }, + defaultFeatureModel: serverSettings.defaultFeatureModel + ? migratePhaseModelEntry(serverSettings.defaultFeatureModel) + : { model: 'claude-opus' }, muteDoneSound: serverSettings.muteDoneSound, serverLogLevel: serverSettings.serverLogLevel ?? 'info', enableRequestLogging: serverSettings.enableRequestLogging ?? true, enhancementModel: serverSettings.enhancementModel, validationModel: serverSettings.validationModel, - phaseModels: serverSettings.phaseModels, - enabledCursorModels: serverSettings.enabledCursorModels, - cursorDefaultModel: serverSettings.cursorDefaultModel, + phaseModels: migratedPhaseModels ?? serverSettings.phaseModels, + enabledCursorModels: allCursorModels, // Always use ALL cursor models + cursorDefaultModel: sanitizedCursorDefault, enabledOpencodeModels: sanitizedEnabledOpencodeModels, opencodeDefaultModel: sanitizedOpencodeDefaultModel, enabledDynamicModelIds: sanitizedDynamicModelIds, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index f6eb6f2e1..c40d9df0a 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -495,10 +495,12 @@ export interface AutoModeAPI { status: (projectPath?: string) => Promise<{ success: boolean; isRunning?: boolean; + isAutoLoopRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; runningProjects?: string[]; runningCount?: number; + maxConcurrency?: number; error?: string; }>; runFeature: ( @@ -3226,7 +3228,7 @@ function createMockGitHubAPI(): GitHubAPI { estimatedComplexity: 'moderate' as const, }, projectPath, - model: model || 'sonnet', + model: model || 'claude-sonnet', }) ); }, 2000); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ee8ca98aa..e05f5dad2 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1393,12 +1393,12 @@ const initialState: AppState = { muteDoneSound: false, // Default to sound enabled (not muted) serverLogLevel: 'info', // Default to info level for server logs enableRequestLogging: true, // Default to enabled for HTTP request logging - enhancementModel: 'sonnet', // Default to sonnet for feature enhancement - validationModel: 'opus', // Default to opus for GitHub issue validation + enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement + validationModel: 'claude-opus', // Default to opus for GitHub issue validation phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration favoriteModels: [], enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default - cursorDefaultModel: 'auto', // Default to auto selection + cursorDefaultModel: 'cursor-auto', // Default to auto selection enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default codexDefaultModel: 'codex-gpt-5.2-codex', // Default to GPT-5.2-Codex codexAutoLoadAgents: false, // Default to disabled (user must opt-in) diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index 6c636f982..df592d9ec 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -6,10 +6,16 @@ * - Passes through Cursor models unchanged (handled by CursorProvider) * - Provides default models per provider * - Handles multiple model sources with priority + * + * With canonical model IDs: + * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2 + * - OpenCode: opencode-big-pickle, opencode-grok-code + * - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases) */ import { CLAUDE_MODEL_MAP, + CLAUDE_CANONICAL_MAP, CURSOR_MODEL_MAP, CODEX_MODEL_MAP, DEFAULT_MODELS, @@ -17,6 +23,7 @@ import { isCursorModel, isOpencodeModel, stripProviderPrefix, + migrateModelId, type PhaseModelEntry, type ThinkingLevel, } from '@automaker/types'; @@ -29,7 +36,11 @@ const OPENAI_O_SERIES_ALLOWED_MODELS = new Set(); /** * Resolve a model key/alias to a full model string * - * @param modelKey - Model key (e.g., "opus", "cursor-composer-1", "claude-sonnet-4-20250514") + * Handles both canonical prefixed IDs and legacy aliases: + * - Canonical: cursor-auto, cursor-gpt-5.2, opencode-big-pickle, claude-sonnet + * - Legacy: auto, composer-1, sonnet, opus + * + * @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-1", "sonnet") * @param defaultModel - Fallback model if modelKey is undefined * @returns Full model string */ @@ -47,74 +58,65 @@ export function resolveModelString( return defaultModel; } - // Cursor model with explicit prefix (e.g., "cursor-composer-1") - pass through unchanged - // CursorProvider will strip the prefix when calling the CLI - if (modelKey.startsWith(PROVIDER_PREFIXES.cursor)) { - const cursorModelId = stripProviderPrefix(modelKey); - // Verify it's a valid Cursor model - if (cursorModelId in CURSOR_MODEL_MAP) { - console.log( - `[ModelResolver] Using Cursor model: ${modelKey} (valid model ID: ${cursorModelId})` - ); - return modelKey; - } - // Could be a cursor-prefixed model not in our map yet - still pass through - console.log(`[ModelResolver] Passing through cursor-prefixed model: ${modelKey}`); - return modelKey; + // First, migrate legacy IDs to canonical format + const canonicalKey = migrateModelId(modelKey); + if (canonicalKey !== modelKey) { + console.log(`[ModelResolver] Migrated legacy ID: "${modelKey}" -> "${canonicalKey}"`); + } + + // Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-1") + // Pass through unchanged - provider will extract bare ID for CLI + if (canonicalKey.startsWith(PROVIDER_PREFIXES.cursor)) { + console.log(`[ModelResolver] Using Cursor model: ${canonicalKey}`); + return canonicalKey; + } + + // Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max") + if (canonicalKey.startsWith(PROVIDER_PREFIXES.codex)) { + console.log(`[ModelResolver] Using Codex model: ${canonicalKey}`); + return canonicalKey; } - // Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max") - pass through unchanged - if (modelKey.startsWith(PROVIDER_PREFIXES.codex)) { - console.log(`[ModelResolver] Using Codex model: ${modelKey}`); - return modelKey; + // OpenCode model (static with opencode- prefix or dynamic with provider/model format) + if (isOpencodeModel(canonicalKey)) { + console.log(`[ModelResolver] Using OpenCode model: ${canonicalKey}`); + return canonicalKey; } - // OpenCode model (static or dynamic) - pass through unchanged - // This handles models like: - // - opencode-* (Automaker routing prefix) - // - opencode/* (free tier models) - // - amazon-bedrock/* (AWS Bedrock models) - // - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro) - if (isOpencodeModel(modelKey)) { - console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`); - return modelKey; + // Claude canonical ID (claude-haiku, claude-sonnet, claude-opus) + // Map to full model string + if (canonicalKey in CLAUDE_CANONICAL_MAP) { + const resolved = CLAUDE_CANONICAL_MAP[canonicalKey as keyof typeof CLAUDE_CANONICAL_MAP]; + console.log(`[ModelResolver] Resolved Claude canonical ID: "${canonicalKey}" -> "${resolved}"`); + return resolved; } - // Full Claude model string - pass through unchanged - if (modelKey.includes('claude-')) { - console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`); - return modelKey; + // Full Claude model string (e.g., claude-sonnet-4-5-20250929) - pass through + if (canonicalKey.includes('claude-')) { + console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`); + return canonicalKey; } - // Look up Claude model alias - const resolved = CLAUDE_MODEL_MAP[modelKey]; + // Legacy Claude model alias (sonnet, opus, haiku) - support for backward compatibility + const resolved = CLAUDE_MODEL_MAP[canonicalKey]; if (resolved) { - console.log(`[ModelResolver] Resolved Claude model alias: "${modelKey}" -> "${resolved}"`); + console.log(`[ModelResolver] Resolved Claude legacy alias: "${canonicalKey}" -> "${resolved}"`); return resolved; } - // OpenAI/Codex models - check for codex- or gpt- prefix + // OpenAI/Codex models - check for gpt- prefix if ( - CODEX_MODEL_PREFIXES.some((prefix) => modelKey.startsWith(prefix)) || - (OPENAI_O_SERIES_PATTERN.test(modelKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(modelKey)) + CODEX_MODEL_PREFIXES.some((prefix) => canonicalKey.startsWith(prefix)) || + (OPENAI_O_SERIES_PATTERN.test(canonicalKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(canonicalKey)) ) { - console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`); - return modelKey; - } - - // Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o") - // Note: This is checked AFTER Codex check to prioritize Codex for bare gpt-* models - if (modelKey in CURSOR_MODEL_MAP) { - // Return with cursor- prefix so provider routing works correctly - const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`; - console.log( - `[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"` - ); - return prefixedModel; + console.log(`[ModelResolver] Using OpenAI/Codex model: ${canonicalKey}`); + return canonicalKey; } // Unknown model key - use default - console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`); + console.warn( + `[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"` + ); return defaultModel; } diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 04452f835..6f99346c3 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -78,8 +78,9 @@ describe('model-resolver', () => { const result = resolveModelString('sonnet'); expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); + // Legacy aliases are migrated to canonical IDs then resolved expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Resolved Claude model alias: "sonnet"') + expect.stringContaining('Migrated legacy ID: "sonnet" -> "claude-sonnet"') ); }); @@ -88,7 +89,7 @@ describe('model-resolver', () => { expect(result).toBe(CLAUDE_MODEL_MAP.opus); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Resolved Claude model alias: "opus"') + expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); }); @@ -101,8 +102,9 @@ describe('model-resolver', () => { it('should log the resolution for aliases', () => { resolveModelString('sonnet'); + // Legacy aliases get migrated and resolved via canonical map expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Resolved Claude model alias') + expect.stringContaining('Resolved Claude canonical ID') ); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining(CLAUDE_MODEL_MAP.sonnet) @@ -134,8 +136,9 @@ describe('model-resolver', () => { const result = resolveModelString('composer-1'); expect(result).toBe('cursor-composer-1'); + // Legacy bare IDs are migrated to canonical prefixed format expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Detected bare Cursor model ID') + expect.stringContaining('Migrated legacy ID: "composer-1" -> "cursor-composer-1"') ); }); @@ -149,17 +152,18 @@ describe('model-resolver', () => { const result = resolveModelString('cursor-unknown-future-model'); expect(result).toBe('cursor-unknown-future-model'); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Passing through cursor-prefixed model') - ); + // Unknown cursor-prefixed models pass through as Cursor models + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model')); }); it('should handle all known Cursor model IDs', () => { + // CURSOR_MODEL_MAP now uses prefixed keys (e.g., 'cursor-auto') const cursorModelIds = Object.keys(CURSOR_MODEL_MAP); for (const modelId of cursorModelIds) { - const result = resolveModelString(`cursor-${modelId}`); - expect(result).toBe(`cursor-${modelId}`); + // modelId is already prefixed (e.g., 'cursor-auto') + const result = resolveModelString(modelId); + expect(result).toBe(modelId); } }); }); diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts index 46244ecd6..08db74d8c 100644 --- a/libs/types/src/cursor-models.ts +++ b/libs/types/src/cursor-models.ts @@ -2,18 +2,19 @@ * Cursor CLI Model IDs * Reference: https://cursor.com/docs * - * IMPORTANT: GPT models use 'cursor-' prefix to distinguish from Codex CLI models + * All Cursor model IDs use 'cursor-' prefix for consistent provider routing. + * This prevents naming collisions (e.g., cursor-gpt-5.2-codex vs codex-gpt-5.2-codex). */ export type CursorModelId = - | 'auto' // Auto-select best model - | 'composer-1' // Cursor Composer agent model - | 'sonnet-4.5' // Claude Sonnet 4.5 - | 'sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking - | 'opus-4.5' // Claude Opus 4.5 - | 'opus-4.5-thinking' // Claude Opus 4.5 with extended thinking - | 'opus-4.1' // Claude Opus 4.1 - | 'gemini-3-pro' // Gemini 3 Pro - | 'gemini-3-flash' // Gemini 3 Flash + | 'cursor-auto' // Auto-select best model + | 'cursor-composer-1' // Cursor Composer agent model + | 'cursor-sonnet-4.5' // Claude Sonnet 4.5 + | 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking + | 'cursor-opus-4.5' // Claude Opus 4.5 + | 'cursor-opus-4.5-thinking' // Claude Opus 4.5 with extended thinking + | 'cursor-opus-4.1' // Claude Opus 4.1 + | 'cursor-gemini-3-pro' // Gemini 3 Pro + | 'cursor-gemini-3-flash' // Gemini 3 Flash | 'cursor-gpt-5.2' // GPT-5.2 via Cursor | 'cursor-gpt-5.1' // GPT-5.1 via Cursor | 'cursor-gpt-5.2-high' // GPT-5.2 High via Cursor @@ -26,7 +27,22 @@ export type CursorModelId = | 'cursor-gpt-5.2-codex-high' // GPT-5.2 Codex High via Cursor | 'cursor-gpt-5.2-codex-max' // GPT-5.2 Codex Max via Cursor | 'cursor-gpt-5.2-codex-max-high' // GPT-5.2 Codex Max High via Cursor - | 'grok'; // Grok + | 'cursor-grok'; // Grok + +/** + * Legacy Cursor model IDs (without prefix) for migration support + */ +export type LegacyCursorModelId = + | 'auto' + | 'composer-1' + | 'sonnet-4.5' + | 'sonnet-4.5-thinking' + | 'opus-4.5' + | 'opus-4.5-thinking' + | 'opus-4.1' + | 'gemini-3-pro' + | 'gemini-3-flash' + | 'grok'; /** * Cursor model metadata @@ -42,66 +58,67 @@ export interface CursorModelConfig { /** * Complete model map for Cursor CLI + * All keys use 'cursor-' prefix for consistent provider routing. */ export const CURSOR_MODEL_MAP: Record = { - auto: { - id: 'auto', + 'cursor-auto': { + id: 'cursor-auto', label: 'Auto (Recommended)', description: 'Automatically selects the best model for each task', hasThinking: false, supportsVision: false, // Vision not yet supported by Cursor CLI }, - 'composer-1': { - id: 'composer-1', + 'cursor-composer-1': { + id: 'cursor-composer-1', label: 'Composer 1', description: 'Cursor Composer agent model optimized for multi-file edits', hasThinking: false, supportsVision: false, }, - 'sonnet-4.5': { - id: 'sonnet-4.5', + 'cursor-sonnet-4.5': { + id: 'cursor-sonnet-4.5', label: 'Claude Sonnet 4.5', description: 'Anthropic Claude Sonnet 4.5 via Cursor', hasThinking: false, supportsVision: false, // Model supports vision but Cursor CLI doesn't pass images }, - 'sonnet-4.5-thinking': { - id: 'sonnet-4.5-thinking', + 'cursor-sonnet-4.5-thinking': { + id: 'cursor-sonnet-4.5-thinking', label: 'Claude Sonnet 4.5 (Thinking)', description: 'Claude Sonnet 4.5 with extended thinking enabled', hasThinking: true, supportsVision: false, }, - 'opus-4.5': { - id: 'opus-4.5', + 'cursor-opus-4.5': { + id: 'cursor-opus-4.5', label: 'Claude Opus 4.5', description: 'Anthropic Claude Opus 4.5 via Cursor', hasThinking: false, supportsVision: false, }, - 'opus-4.5-thinking': { - id: 'opus-4.5-thinking', + 'cursor-opus-4.5-thinking': { + id: 'cursor-opus-4.5-thinking', label: 'Claude Opus 4.5 (Thinking)', description: 'Claude Opus 4.5 with extended thinking enabled', hasThinking: true, supportsVision: false, }, - 'opus-4.1': { - id: 'opus-4.1', + 'cursor-opus-4.1': { + id: 'cursor-opus-4.1', label: 'Claude Opus 4.1', description: 'Anthropic Claude Opus 4.1 via Cursor', hasThinking: false, supportsVision: false, }, - 'gemini-3-pro': { - id: 'gemini-3-pro', + 'cursor-gemini-3-pro': { + id: 'cursor-gemini-3-pro', label: 'Gemini 3 Pro', description: 'Google Gemini 3 Pro via Cursor', hasThinking: false, supportsVision: false, }, - 'gemini-3-flash': { - id: 'gemini-3-flash', + 'cursor-gemini-3-flash': { + id: 'cursor-gemini-3-flash', label: 'Gemini 3 Flash', description: 'Google Gemini 3 Flash (faster)', hasThinking: false, @@ -191,8 +208,8 @@ export const CURSOR_MODEL_MAP: Record = { hasThinking: false, supportsVision: false, }, - grok: { - id: 'grok', + 'cursor-grok': { + id: 'cursor-grok', label: 'Grok', description: 'xAI Grok via Cursor', hasThinking: false, @@ -200,6 +217,22 @@ export const CURSOR_MODEL_MAP: Record = { }, }; +/** + * Map from legacy model IDs to canonical prefixed IDs + */ +export const LEGACY_CURSOR_MODEL_MAP: Record = { + auto: 'cursor-auto', + 'composer-1': 'cursor-composer-1', + 'sonnet-4.5': 'cursor-sonnet-4.5', + 'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking', + 'opus-4.5': 'cursor-opus-4.5', + 'opus-4.5-thinking': 'cursor-opus-4.5-thinking', + 'opus-4.1': 'cursor-opus-4.1', + 'gemini-3-pro': 'cursor-gemini-3-pro', + 'gemini-3-flash': 'cursor-gemini-3-flash', + grok: 'cursor-grok', +}; + /** * Helper: Check if model has thinking capability */ @@ -254,6 +287,7 @@ export interface GroupedModel { /** * Configuration for grouping Cursor models with variants + * All variant IDs use 'cursor-' prefix for consistent provider routing. */ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ // GPT-5.2 group (compute levels) @@ -346,14 +380,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ }, // Sonnet 4.5 group (thinking mode) { - baseId: 'sonnet-4.5-group', + baseId: 'cursor-sonnet-4.5-group', label: 'Claude Sonnet 4.5', description: 'Anthropic Claude Sonnet 4.5 via Cursor', variantType: 'thinking', variants: [ - { id: 'sonnet-4.5', label: 'Standard', description: 'Fast responses' }, + { id: 'cursor-sonnet-4.5', label: 'Standard', description: 'Fast responses' }, { - id: 'sonnet-4.5-thinking', + id: 'cursor-sonnet-4.5-thinking', label: 'Thinking', description: 'Extended reasoning', badge: 'Reasoning', @@ -362,14 +396,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ }, // Opus 4.5 group (thinking mode) { - baseId: 'opus-4.5-group', + baseId: 'cursor-opus-4.5-group', label: 'Claude Opus 4.5', description: 'Anthropic Claude Opus 4.5 via Cursor', variantType: 'thinking', variants: [ - { id: 'opus-4.5', label: 'Standard', description: 'Fast responses' }, + { id: 'cursor-opus-4.5', label: 'Standard', description: 'Fast responses' }, { - id: 'opus-4.5-thinking', + id: 'cursor-opus-4.5-thinking', label: 'Thinking', description: 'Extended reasoning', badge: 'Reasoning', @@ -380,14 +414,15 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ /** * Cursor models that are not part of any group (standalone) + * All IDs use 'cursor-' prefix for consistent provider routing. */ export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [ - 'auto', - 'composer-1', - 'opus-4.1', - 'gemini-3-pro', - 'gemini-3-flash', - 'grok', + 'cursor-auto', + 'cursor-composer-1', + 'cursor-opus-4.1', + 'cursor-gemini-3-pro', + 'cursor-gemini-3-flash', + 'cursor-grok', ]; /** diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 4a8c6af13..7b5388a02 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -77,12 +77,15 @@ export type { ImageData, ImageContentBlock } from './image.js'; // Model types and constants export { CLAUDE_MODEL_MAP, + CLAUDE_CANONICAL_MAP, + LEGACY_CLAUDE_ALIAS_MAP, CODEX_MODEL_MAP, CODEX_MODEL_IDS, REASONING_CAPABLE_MODELS, supportsReasoningEffort, getAllCodexModelIds, DEFAULT_MODELS, + type ClaudeCanonicalId, type ModelAlias, type CodexModelId, type AgentModel, @@ -237,6 +240,18 @@ export { validateBareModelId, } from './provider-utils.js'; +// Model migration utilities +export { + isLegacyCursorModelId, + isLegacyOpencodeModelId, + isLegacyClaudeAlias, + migrateModelId, + migrateCursorModelIds, + migrateOpencodeModelIds, + migratePhaseModelEntry, + getBareModelIdForCli, +} from './model-migration.js'; + // Pipeline types export type { PipelineStep, diff --git a/libs/types/src/model-migration.ts b/libs/types/src/model-migration.ts new file mode 100644 index 000000000..49e28c8e9 --- /dev/null +++ b/libs/types/src/model-migration.ts @@ -0,0 +1,218 @@ +/** + * Model ID Migration Utilities + * + * Provides functions to migrate legacy model IDs to the canonical prefixed format. + * This ensures backward compatibility when loading settings from older versions. + */ + +import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js'; +import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js'; +import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js'; +import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js'; +import type { ClaudeCanonicalId } from './model.js'; +import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js'; +import type { PhaseModelEntry } from './settings.js'; + +/** + * Check if a string is a legacy Cursor model ID (without prefix) + */ +export function isLegacyCursorModelId(id: string): id is LegacyCursorModelId { + return id in LEGACY_CURSOR_MODEL_MAP; +} + +/** + * Check if a string is a legacy OpenCode model ID (with slash format) + */ +export function isLegacyOpencodeModelId(id: string): id is LegacyOpencodeModelId { + return id in LEGACY_OPENCODE_MODEL_MAP; +} + +/** + * Check if a string is a legacy Claude alias (short name without prefix) + */ +export function isLegacyClaudeAlias(id: string): boolean { + return id in LEGACY_CLAUDE_ALIAS_MAP; +} + +/** + * Migrate a single model ID to canonical format + * + * Handles: + * - Legacy Cursor IDs (e.g., 'auto' -> 'cursor-auto') + * - Legacy OpenCode IDs (e.g., 'opencode/big-pickle' -> 'opencode-big-pickle') + * - Legacy Claude aliases (e.g., 'sonnet' -> 'claude-sonnet') + * - Already-canonical IDs are passed through unchanged + * + * @param legacyId - The model ID to migrate + * @returns The canonical model ID + */ +export function migrateModelId(legacyId: string | undefined | null): string { + if (!legacyId) { + return legacyId as string; + } + + // Already has cursor- prefix and is in the map - it's canonical + if (legacyId.startsWith('cursor-') && legacyId in CURSOR_MODEL_MAP) { + return legacyId; + } + + // Legacy Cursor model ID (without prefix) + if (isLegacyCursorModelId(legacyId)) { + return LEGACY_CURSOR_MODEL_MAP[legacyId]; + } + + // Already has opencode- prefix - it's canonical + if (legacyId.startsWith('opencode-') && legacyId in OPENCODE_MODEL_CONFIG_MAP) { + return legacyId; + } + + // Legacy OpenCode model ID (with slash format) + if (isLegacyOpencodeModelId(legacyId)) { + return LEGACY_OPENCODE_MODEL_MAP[legacyId]; + } + + // Already has claude- prefix and is in canonical map + if (legacyId.startsWith('claude-') && legacyId in CLAUDE_CANONICAL_MAP) { + return legacyId; + } + + // Legacy Claude alias (short name) + if (isLegacyClaudeAlias(legacyId)) { + return LEGACY_CLAUDE_ALIAS_MAP[legacyId]; + } + + // Unknown or already canonical - pass through + return legacyId; +} + +/** + * Migrate an array of Cursor model IDs to canonical format + * + * @param ids - Array of legacy or canonical Cursor model IDs + * @returns Array of canonical Cursor model IDs + */ +export function migrateCursorModelIds(ids: string[]): CursorModelId[] { + if (!ids || !Array.isArray(ids)) { + return []; + } + + return ids.map((id) => { + // Already canonical + if (id.startsWith('cursor-') && id in CURSOR_MODEL_MAP) { + return id as CursorModelId; + } + + // Legacy ID + if (isLegacyCursorModelId(id)) { + return LEGACY_CURSOR_MODEL_MAP[id]; + } + + // Unknown - assume it might be a valid cursor model with prefix + if (id.startsWith('cursor-')) { + return id as CursorModelId; + } + + // Add prefix if not present + return `cursor-${id}` as CursorModelId; + }); +} + +/** + * Migrate an array of OpenCode model IDs to canonical format + * + * @param ids - Array of legacy or canonical OpenCode model IDs + * @returns Array of canonical OpenCode model IDs + */ +export function migrateOpencodeModelIds(ids: string[]): OpencodeModelId[] { + if (!ids || !Array.isArray(ids)) { + return []; + } + + return ids.map((id) => { + // Already canonical (dash format) + if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) { + return id as OpencodeModelId; + } + + // Legacy ID (slash format) + if (isLegacyOpencodeModelId(id)) { + return LEGACY_OPENCODE_MODEL_MAP[id]; + } + + // Convert slash to dash format for unknown models + if (id.startsWith('opencode/')) { + return id.replace('opencode/', 'opencode-') as OpencodeModelId; + } + + // Add prefix if not present + if (!id.startsWith('opencode-')) { + return `opencode-${id}` as OpencodeModelId; + } + + return id as OpencodeModelId; + }); +} + +/** + * Migrate a PhaseModelEntry to use canonical model IDs + * + * @param entry - The phase model entry to migrate + * @returns Migrated phase model entry with canonical model ID + */ +export function migratePhaseModelEntry( + entry: PhaseModelEntry | string | undefined | null +): PhaseModelEntry { + // Handle null/undefined + if (!entry) { + return { model: 'claude-sonnet' }; // Default + } + + // Handle legacy string format + if (typeof entry === 'string') { + return { model: migrateModelId(entry) }; + } + + // Handle PhaseModelEntry object + return { + ...entry, + model: migrateModelId(entry.model), + }; +} + +/** + * Get the bare model ID for CLI calls (strip provider prefix) + * + * When calling provider CLIs, we need to strip the provider prefix: + * - 'cursor-auto' -> 'auto' (for Cursor CLI) + * - 'cursor-composer-1' -> 'composer-1' (for Cursor CLI) + * - 'opencode-big-pickle' -> 'big-pickle' (for OpenCode CLI) + * + * Note: GPT models via Cursor keep the gpt- part: 'cursor-gpt-5.2' -> 'gpt-5.2' + * + * @param modelId - The canonical model ID with provider prefix + * @returns The bare model ID for CLI usage + */ +export function getBareModelIdForCli(modelId: string): string { + if (!modelId) return modelId; + + // Cursor models + if (modelId.startsWith('cursor-')) { + const bareId = modelId.slice(7); // Remove 'cursor-' + // For GPT models, keep the gpt- prefix since that's what the CLI expects + // e.g., 'cursor-gpt-5.2' -> 'gpt-5.2' + return bareId; + } + + // OpenCode models - strip prefix + if (modelId.startsWith('opencode-')) { + return modelId.slice(9); // Remove 'opencode-' + } + + // Codex models - strip prefix + if (modelId.startsWith('codex-')) { + return modelId.slice(6); // Remove 'codex-' + } + + // Claude and other models - pass through + return modelId; +} diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 949938c99..2973a8927 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -4,12 +4,42 @@ import type { CursorModelId } from './cursor-models.js'; import type { OpencodeModelId } from './opencode-models.js'; +/** + * Canonical Claude model IDs with provider prefix + * Used for internal storage and consistent provider routing. + */ +export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus'; + +/** + * Canonical Claude model map - maps prefixed IDs to full model strings + * Use these IDs for internal storage and routing. + */ +export const CLAUDE_CANONICAL_MAP: Record = { + 'claude-haiku': 'claude-haiku-4-5-20251001', + 'claude-sonnet': 'claude-sonnet-4-5-20250929', + 'claude-opus': 'claude-opus-4-5-20251101', +} as const; + +/** + * Legacy Claude model aliases (short names) for backward compatibility + * These map to the same full model strings as the canonical map. + * @deprecated Use CLAUDE_CANONICAL_MAP for new code + */ export const CLAUDE_MODEL_MAP: Record = { haiku: 'claude-haiku-4-5-20251001', sonnet: 'claude-sonnet-4-5-20250929', opus: 'claude-opus-4-5-20251101', } as const; +/** + * Map from legacy aliases to canonical IDs + */ +export const LEGACY_CLAUDE_ALIAS_MAP: Record = { + haiku: 'claude-haiku', + sonnet: 'claude-sonnet', + opus: 'claude-opus', +} as const; + /** * Codex/OpenAI model identifiers * Based on OpenAI Codex CLI official models @@ -62,10 +92,11 @@ export function getAllCodexModelIds(): CodexModelId[] { /** * Default models per provider + * Uses canonical prefixed IDs for consistent routing. */ export const DEFAULT_MODELS = { claude: 'claude-opus-4-5-20251101', - cursor: 'auto', // Cursor's recommended default + cursor: 'cursor-auto', // Cursor's recommended default (with prefix) codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model } as const; diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts index 21d5a6522..de96f96be 100644 --- a/libs/types/src/opencode-models.ts +++ b/libs/types/src/opencode-models.ts @@ -1,9 +1,22 @@ /** * OpenCode Model IDs * Models available via OpenCode CLI (opencode models command) + * + * All OpenCode model IDs use 'opencode-' prefix for consistent provider routing. + * This prevents naming collisions and ensures clear provider attribution. */ export type OpencodeModelId = // OpenCode Free Tier Models + | 'opencode-big-pickle' + | 'opencode-glm-4.7-free' + | 'opencode-gpt-5-nano' + | 'opencode-grok-code' + | 'opencode-minimax-m2.1-free'; + +/** + * Legacy OpenCode model IDs (with slash format) for migration support + */ +export type LegacyOpencodeModelId = | 'opencode/big-pickle' | 'opencode/glm-4.7-free' | 'opencode/gpt-5-nano' @@ -20,16 +33,27 @@ export type OpencodeProvider = 'opencode'; */ export const OPENCODE_MODEL_MAP: Record = { // OpenCode free tier aliases - 'big-pickle': 'opencode/big-pickle', - pickle: 'opencode/big-pickle', - 'glm-free': 'opencode/glm-4.7-free', - 'gpt-nano': 'opencode/gpt-5-nano', - nano: 'opencode/gpt-5-nano', - 'grok-code': 'opencode/grok-code', - grok: 'opencode/grok-code', - minimax: 'opencode/minimax-m2.1-free', + 'big-pickle': 'opencode-big-pickle', + pickle: 'opencode-big-pickle', + 'glm-free': 'opencode-glm-4.7-free', + 'gpt-nano': 'opencode-gpt-5-nano', + nano: 'opencode-gpt-5-nano', + 'grok-code': 'opencode-grok-code', + grok: 'opencode-grok-code', + minimax: 'opencode-minimax-m2.1-free', } as const; +/** + * Map from legacy slash-format model IDs to canonical prefixed IDs + */ +export const LEGACY_OPENCODE_MODEL_MAP: Record = { + 'opencode/big-pickle': 'opencode-big-pickle', + 'opencode/glm-4.7-free': 'opencode-glm-4.7-free', + 'opencode/gpt-5-nano': 'opencode-gpt-5-nano', + 'opencode/grok-code': 'opencode-grok-code', + 'opencode/minimax-m2.1-free': 'opencode-minimax-m2.1-free', +}; + /** * OpenCode model metadata */ @@ -44,11 +68,12 @@ export interface OpencodeModelConfig { /** * Complete list of OpenCode model configurations + * All IDs use 'opencode-' prefix for consistent provider routing. */ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ // OpenCode Free Tier Models { - id: 'opencode/big-pickle', + id: 'opencode-big-pickle', label: 'Big Pickle', description: 'OpenCode free tier model - great for general coding', supportsVision: false, @@ -56,7 +81,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ tier: 'free', }, { - id: 'opencode/glm-4.7-free', + id: 'opencode-glm-4.7-free', label: 'GLM 4.7 Free', description: 'OpenCode free tier GLM model', supportsVision: false, @@ -64,7 +89,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ tier: 'free', }, { - id: 'opencode/gpt-5-nano', + id: 'opencode-gpt-5-nano', label: 'GPT-5 Nano', description: 'OpenCode free tier nano model - fast and lightweight', supportsVision: false, @@ -72,7 +97,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ tier: 'free', }, { - id: 'opencode/grok-code', + id: 'opencode-grok-code', label: 'Grok Code', description: 'OpenCode free tier Grok model for coding', supportsVision: false, @@ -80,7 +105,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ tier: 'free', }, { - id: 'opencode/minimax-m2.1-free', + id: 'opencode-minimax-m2.1-free', label: 'MiniMax M2.1 Free', description: 'OpenCode free tier MiniMax model', supportsVision: false, @@ -104,7 +129,7 @@ export const OPENCODE_MODEL_CONFIG_MAP: Record; + + // Auto Mode Configuration (per-project) + /** Whether auto mode is enabled for this project (backend-controlled loop) */ + automodeEnabled?: boolean; + /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ + maxConcurrentAgents?: number; } /** * Default values and constants */ -/** Default phase model configuration - sensible defaults for each task type */ +/** Default phase model configuration - sensible defaults for each task type + * Uses canonical prefixed model IDs for consistent routing. + */ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { // Quick tasks - use fast models for speed and cost - enhancementModel: { model: 'sonnet' }, - fileDescriptionModel: { model: 'haiku' }, - imageDescriptionModel: { model: 'haiku' }, + enhancementModel: { model: 'claude-sonnet' }, + fileDescriptionModel: { model: 'claude-haiku' }, + imageDescriptionModel: { model: 'claude-haiku' }, // Validation - use smart models for accuracy - validationModel: { model: 'sonnet' }, + validationModel: { model: 'claude-sonnet' }, // Generation - use powerful models for quality - specGenerationModel: { model: 'opus' }, - featureGenerationModel: { model: 'sonnet' }, - backlogPlanningModel: { model: 'sonnet' }, - projectAnalysisModel: { model: 'sonnet' }, - suggestionsModel: { model: 'sonnet' }, + specGenerationModel: { model: 'claude-opus' }, + featureGenerationModel: { model: 'claude-sonnet' }, + backlogPlanningModel: { model: 'claude-sonnet' }, + projectAnalysisModel: { model: 'claude-sonnet' }, + suggestionsModel: { model: 'claude-sonnet' }, // Memory - use fast model for learning extraction (cost-effective) - memoryExtractionModel: { model: 'haiku' }, + memoryExtractionModel: { model: 'claude-haiku' }, // Commit messages - use fast model for speed - commitMessageModel: { model: 'haiku' }, + commitMessageModel: { model: 'claude-haiku' }, }; /** Current version of the global settings schema */ @@ -857,18 +865,18 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { useWorktrees: true, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, - defaultFeatureModel: { model: 'opus' }, + defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID muteDoneSound: false, serverLogLevel: 'info', enableRequestLogging: true, enableAiCommitMessages: true, phaseModels: DEFAULT_PHASE_MODELS, - enhancementModel: 'sonnet', - validationModel: 'opus', - enabledCursorModels: getAllCursorModelIds(), - cursorDefaultModel: 'auto', - enabledOpencodeModels: getAllOpencodeModelIds(), - opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, + enhancementModel: 'sonnet', // Legacy alias still supported + validationModel: 'opus', // Legacy alias still supported + enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs + cursorDefaultModel: 'cursor-auto', // Use canonical prefixed ID + enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed enabledDynamicModelIds: [], disabledProviders: [], keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,