feat: implement cursor model migration and enhance auto mode functionality

This commit introduces significant updates to the cursor model handling and auto mode features. The cursor model IDs have been standardized to a canonical format, ensuring backward compatibility while migrating legacy IDs. New endpoints for starting and stopping the auto mode loop have been added, allowing for better control over project-specific auto mode operations.

Key changes:
- Updated cursor model IDs to use the 'cursor-' prefix for consistency.
- Added new API endpoints: `/start` and `/stop` for managing auto mode.
- Enhanced the status endpoint to provide detailed project-specific auto mode information.
- Improved error handling and logging throughout the auto mode service.
- Migrated legacy model IDs to their canonical counterparts in various components.

This update aims to streamline the user experience and ensure a smooth transition for existing users while providing new functionalities.
This commit is contained in:
webdevcody
2026-01-18 18:42:52 -05:00
parent 3faebfa3fe
commit 4b0d1399b1
36 changed files with 1508 additions and 592 deletions

View File

@@ -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();

View File

@@ -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(

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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');

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -100,11 +100,60 @@ export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
/**
* 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.';
}
return String(error);
// 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) {
rawMessage = error.message;
} else {
rawMessage = String(error);
}
return mapBacklogPlanError(rawMessage);
}
export function logError(error: unknown, context: string): void {

View File

@@ -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);

View File

@@ -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<string, RunningFeature>();
private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader();
// Per-project autoloop state (supports multiple concurrent projects)
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
// 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<string, PendingApproval>();
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<void> {
// 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<void> {
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<number> {
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<void> {
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<void> {
// 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<void> {
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<number> {
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 [];
}
}

View File

@@ -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: already a PhaseModelEntry object
return value;
// 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<GlobalSettings>): Partial<GlobalSettings> {
const migrated: Partial<GlobalSettings> = { ...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];
}
}
// 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;
}
/**

View File

@@ -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"')
);
});

View File

@@ -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();

View File

@@ -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',
});
});

View File

@@ -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';
}

View File

@@ -42,7 +42,7 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@@ -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<Set<string>>(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)}

View File

@@ -170,7 +170,7 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);

View File

@@ -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<PhaseModelEntry>(() => ({
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',
});

View File

@@ -126,7 +126,7 @@ export function MassEditDialog({
});
// Field values
const [model, setModel] = useState<ModelAlias>('sonnet');
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
const [planningMode, setPlanningMode] = useState<PlanningMode>('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));

View File

@@ -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,

View File

@@ -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');
}
};

View File

@@ -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<string>();
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

View File

@@ -92,7 +92,8 @@ export function CursorModelConfiguration({
<div className="grid gap-3">
{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 (
<div

View File

@@ -94,21 +94,33 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
// This is intentionally session-scoped to avoid auto-running features after a full app restart.
// On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh.
useEffect(() => {
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;
if (desired !== isAutoModeRunning) {
const result = await api.autoMode.status(currentProject.path);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) {
logger.info(
`[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(currentProject.id, desired);
setAutoModeRunning(currentProject.id, backendIsRunning);
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
}
}
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
};
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;
}
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);
logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
// 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;
}
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);
// 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.
// 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

View File

@@ -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,

View File

@@ -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,17 +507,35 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
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)))
// 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 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<boolean> {
(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<boolean> {
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,

View File

@@ -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);

View File

@@ -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)

View File

@@ -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<string>();
/**
* 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}"`);
}
// 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;
// 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;
}
// 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;
// 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;
}
// Full Claude model string - pass through unchanged
if (modelKey.includes('claude-')) {
console.log(`[ModelResolver] Using full Claude model string: ${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;
}
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved Claude model alias: "${modelKey}" -> "${resolved}"`);
// 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;
}
// OpenAI/Codex models - check for codex- or gpt- prefix
if (
CODEX_MODEL_PREFIXES.some((prefix) => modelKey.startsWith(prefix)) ||
(OPENAI_O_SERIES_PATTERN.test(modelKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(modelKey))
) {
console.log(`[ModelResolver] Using OpenAI/Codex model: ${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;
}
// 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;
// Legacy Claude model alias (sonnet, opus, haiku) - support for backward compatibility
const resolved = CLAUDE_MODEL_MAP[canonicalKey];
if (resolved) {
console.log(`[ModelResolver] Resolved Claude legacy alias: "${canonicalKey}" -> "${resolved}"`);
return resolved;
}
// OpenAI/Codex models - check for gpt- prefix
if (
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: ${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;
}

View File

@@ -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);
}
});
});

View File

@@ -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<CursorModelId, CursorModelConfig> = {
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<CursorModelId, CursorModelConfig> = {
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<CursorModelId, CursorModelConfig> = {
},
};
/**
* Map from legacy model IDs to canonical prefixed IDs
*/
export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId> = {
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',
];
/**

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<ClaudeCanonicalId, string> = {
'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<string, string> = {
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<string, ClaudeCanonicalId> = {
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;

View File

@@ -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<string, OpencodeModelId> = {
// 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<LegacyOpencodeModelId, OpencodeModelId> = {
'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<OpencodeModelId, OpencodeModelCon
/**
* Default OpenCode model - OpenCode free tier
*/
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = 'opencode/big-pickle';
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = 'opencode-big-pickle';
/**
* Helper: Get display name for model

View File

@@ -7,9 +7,9 @@
*/
import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP } from './cursor-models.js';
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
/** Provider prefix constants */
export const PROVIDER_PREFIXES = {
@@ -21,20 +21,23 @@ export const PROVIDER_PREFIXES = {
/**
* Check if a model string represents a Cursor model
*
* @param model - Model string to check (e.g., "cursor-composer-1" or "composer-1")
* @returns true if the model is a Cursor model (excluding Codex-specific models)
* With canonical model IDs, Cursor models always have 'cursor-' prefix.
* Legacy IDs without prefix are handled by migration utilities.
*
* @param model - Model string to check (e.g., "cursor-auto", "cursor-composer-1")
* @returns true if the model is a Cursor model
*/
export function isCursorModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check for explicit cursor- prefix
// Canonical format: all Cursor models have cursor- prefix
if (model.startsWith(PROVIDER_PREFIXES.cursor)) {
return true;
}
// Check if it's a bare Cursor model ID (excluding Codex-specific models)
// Codex-specific models should always route to Codex provider, not Cursor
if (model in CURSOR_MODEL_MAP) {
// Legacy support: check if it's a known legacy bare ID
// This handles transition period before migration
if (model in LEGACY_CURSOR_MODEL_MAP) {
return true;
}
@@ -90,12 +93,14 @@ export function isCodexModel(model: string | undefined | null): boolean {
/**
* Check if a model string represents an OpenCode model
*
* With canonical model IDs, static OpenCode models use 'opencode-' prefix.
* Dynamic models from OpenCode CLI still use provider/model format.
*
* OpenCode models can be identified by:
* - Explicit 'opencode-' prefix (for routing in Automaker)
* - 'opencode/' prefix (OpenCode free tier models)
* - 'opencode-' prefix (canonical format for static models)
* - 'opencode/' prefix (legacy format, will be migrated)
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
* - Full model ID from OPENCODE_MODEL_CONFIG_MAP
* - Dynamic models from OpenCode CLI with provider/model format (e.g., "github-copilot/gpt-4o", "google/gemini-2.5-pro")
* - Dynamic models with provider/model format (e.g., "github-copilot/gpt-4o")
*
* @param model - Model string to check
* @returns true if the model is an OpenCode model
@@ -103,19 +108,18 @@ export function isCodexModel(model: string | undefined | null): boolean {
export function isOpencodeModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check for explicit opencode- prefix (Automaker routing prefix)
// Canonical format: opencode- prefix for static models
if (model.startsWith(PROVIDER_PREFIXES.opencode)) {
return true;
}
// Check if it's a known OpenCode model ID
// Check if it's a known OpenCode model ID (handles both formats during transition)
if (model in OPENCODE_MODEL_CONFIG_MAP) {
return true;
}
// Check for OpenCode native model prefixes
// - opencode/ = OpenCode free tier models
// - amazon-bedrock/ = AWS Bedrock models
// Legacy format: opencode/ prefix (will be migrated to opencode-)
// Also supports amazon-bedrock/ for AWS Bedrock models
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
return true;
}
@@ -228,32 +232,47 @@ export function getBareModelId(model: string): string {
/**
* Normalize a model string to its canonical form
* - For Cursor: adds cursor- prefix if missing
* - For Codex: can add codex- prefix (but bare gpt-* is also valid)
* - For Claude: returns as-is
*
* With the new canonical format:
* - Cursor models: always have cursor- prefix
* - OpenCode models: always have opencode- prefix (static) or provider/model format (dynamic)
* - Claude models: can use legacy aliases or claude- prefix
* - Codex models: always have codex- prefix
*
* @param model - Model string to normalize
* @returns Normalized model string
*/
export function normalizeModelString(model: string | undefined | null): string {
if (!model || typeof model !== 'string') return 'sonnet'; // Default
if (!model || typeof model !== 'string') return 'claude-sonnet'; // Default to canonical
// If it's a Cursor model without prefix, add the prefix
if (model in CURSOR_MODEL_MAP && !model.startsWith(PROVIDER_PREFIXES.cursor)) {
return `${PROVIDER_PREFIXES.cursor}${model}`;
}
// For Codex, bare gpt-* and o-series models are valid canonical forms
// Check if it's in the CODEX_MODEL_MAP
if (model in CODEX_MODEL_MAP) {
// If it already starts with gpt- or o, it's canonical
if (model.startsWith('gpt-') || /^o\d/.test(model)) {
// Already has a canonical prefix - return as-is
if (
model.startsWith(PROVIDER_PREFIXES.cursor) ||
model.startsWith(PROVIDER_PREFIXES.codex) ||
model.startsWith(PROVIDER_PREFIXES.opencode) ||
model.startsWith('claude-')
) {
return model;
}
// Otherwise, it might need a prefix (though this is unlikely)
if (!model.startsWith(PROVIDER_PREFIXES.codex)) {
return `${PROVIDER_PREFIXES.codex}${model}`;
// Check if it's a legacy Cursor model ID
if (model in LEGACY_CURSOR_MODEL_MAP) {
return LEGACY_CURSOR_MODEL_MAP[model as keyof typeof LEGACY_CURSOR_MODEL_MAP];
}
// Check if it's a legacy OpenCode model ID
if (model in LEGACY_OPENCODE_MODEL_MAP) {
return LEGACY_OPENCODE_MODEL_MAP[model as keyof typeof LEGACY_OPENCODE_MODEL_MAP];
}
// Legacy Claude aliases
if (model in CLAUDE_MODEL_MAP) {
return `claude-${model}`;
}
// For Codex, bare gpt-* and o-series models need codex- prefix
if (model.startsWith('gpt-') || /^o\d/.test(model)) {
return `${PROVIDER_PREFIXES.codex}${model}`;
}
return model;

View File

@@ -780,34 +780,42 @@ export interface ProjectSettings {
* Value: agent configuration
*/
customSubagents?: Record<string, import('./provider.js').AgentDefinition>;
// 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,