diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 1deef0db..60cb2d58 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session'; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens +/** + * Check if an environment variable is set to 'true' + */ +function isEnvTrue(envVar: string | undefined): boolean { + return envVar === 'true'; +} + // Session store - persisted to file for survival across server restarts const validSessions = new Map(); @@ -134,8 +141,8 @@ const API_KEY = ensureApiKey(); const BOX_CONTENT_WIDTH = 67; // Print API key to console for web mode users (unless suppressed for production logging) -if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') { - const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true'; +if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) { + const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN); const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled'; // Build box lines with exact padding @@ -375,6 +382,12 @@ function checkAuthentication( * 5. Session cookie (for web mode) */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + // Allow disabling auth for local/trusted networks + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) { + next(); + return; + } + const result = checkAuthentication( req.headers as Record, req.query as Record, @@ -420,9 +433,10 @@ export function isAuthEnabled(): boolean { * Get authentication status for health endpoint */ export function getAuthStatus(): { enabled: boolean; method: string } { + const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH); return { - enabled: true, - method: 'api_key_or_session', + enabled: !disabled, + method: disabled ? 'disabled' : 'api_key_or_session', }; } @@ -430,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } { * Check if a request is authenticated (for status endpoint) */ export function isRequestAuthenticated(req: Request): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; const result = checkAuthentication( req.headers as Record, req.query as Record, @@ -447,5 +462,6 @@ export function checkRawAuthentication( query: Record, cookies: Record ): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; return checkAuthentication(headers, query, cookies).authenticated; } diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index a1bdc4e5..64f3caee 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -5,7 +5,12 @@ import type { SettingsService } from '../services/settings-service.js'; import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; import { createLogger } from '@automaker/utils'; -import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types'; +import type { + MCPServerConfig, + McpServerConfig, + PromptCustomization, + ClaudeApiProfile, +} from '@automaker/types'; import { mergeAutoModePrompts, mergeAgentPrompts, @@ -345,3 +350,80 @@ export async function getCustomSubagents( return Object.keys(merged).length > 0 ? merged : undefined; } + +/** Result from getActiveClaudeApiProfile */ +export interface ActiveClaudeApiProfileResult { + /** The active profile, or undefined if using direct Anthropic API */ + profile: ClaudeApiProfile | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: import('@automaker/types').Credentials | undefined; +} + +/** + * Get the active Claude API profile and credentials from settings. + * Checks project settings first for per-project overrides, then falls back to global settings. + * Returns both the profile and credentials for resolving 'credentials' apiKeySource. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @param projectPath - Optional project path for per-project override + * @returns Promise resolving to object with profile and credentials + */ +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string +): Promise { + if (!settingsService) { + return { profile: undefined, credentials: undefined }; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const profiles = globalSettings.claudeApiProfiles || []; + + // Check for project-level override first + let activeProfileId: string | null | undefined; + let isProjectOverride = false; + + if (projectPath) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + // undefined = use global, null = explicit no profile, string = specific profile + if (projectSettings.activeClaudeApiProfileId !== undefined) { + activeProfileId = projectSettings.activeClaudeApiProfileId; + isProjectOverride = true; + } + } + + // Fall back to global if project doesn't specify + if (activeProfileId === undefined && !isProjectOverride) { + activeProfileId = globalSettings.activeClaudeApiProfileId; + } + + // No active profile selected - use direct Anthropic API + if (!activeProfileId) { + if (isProjectOverride && activeProfileId === null) { + logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`); + } + return { profile: undefined, credentials }; + } + + // Find the active profile by ID + const activeProfile = profiles.find((p) => p.id === activeProfileId); + + if (activeProfile) { + const overrideSuffix = isProjectOverride ? ' (project override)' : ''; + logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`); + return { profile: activeProfile, credentials }; + } else { + logger.warn( + `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API` + ); + return { profile: undefined, credentials }; + } + } catch (error) { + logger.error(`${logPrefix} Failed to load Claude API profile:`, error); + return { profile: undefined, credentials: undefined }; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index f8a31d81..e4c8ad79 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; const logger = createLogger('ClaudeProvider'); -import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types'; +import { + getThinkingTokenBudget, + validateBareModelId, + type ClaudeApiProfile, + type Credentials, +} from '@automaker/types'; import type { ExecuteOptions, ProviderMessage, @@ -21,9 +26,19 @@ import type { // Explicit allowlist of environment variables to pass to the SDK. // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ + // Authentication 'ANTHROPIC_API_KEY', - 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', + // Endpoint configuration + 'ANTHROPIC_BASE_URL', + 'API_TIMEOUT_MS', + // Model mappings + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + // Traffic control + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + // System vars (always from process.env) 'PATH', 'HOME', 'SHELL', @@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [ 'LC_ALL', ]; +// System vars are always passed from process.env regardless of profile +const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; + /** - * Build environment for the SDK with only explicitly allowed variables + * Build environment for the SDK with only explicitly allowed variables. + * When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env). + * When no profile is provided, uses direct Anthropic API settings from process.env. + * + * @param profile - Optional Claude API profile for alternative endpoint configuration + * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource */ -function buildEnv(): Record { +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials +): Record { const env: Record = {}; - for (const key of ALLOWED_ENV_VARS) { + + if (profile) { + // Use profile configuration (clean switch - don't inherit non-system vars from process.env) + logger.debug('Building environment from Claude API profile:', { + name: profile.name, + apiKeySource: profile.apiKeySource ?? 'inline', + }); + + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // Warn if no API key found + if (!apiKey) { + logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`); + } + + // Authentication + if (profile.useAuthToken) { + env['ANTHROPIC_AUTH_TOKEN'] = apiKey; + } else { + env['ANTHROPIC_API_KEY'] = apiKey; + } + + // Endpoint configuration + env['ANTHROPIC_BASE_URL'] = profile.baseUrl; + + if (profile.timeoutMs) { + env['API_TIMEOUT_MS'] = String(profile.timeoutMs); + } + + // Model mappings + if (profile.modelMappings?.haiku) { + env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku; + } + if (profile.modelMappings?.sonnet) { + env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet; + } + if (profile.modelMappings?.opus) { + env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus; + } + + // Traffic control + if (profile.disableNonessentialTraffic) { + env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; + } + } else { + // Use direct Anthropic API - pass through credentials or environment variables + // This supports: + // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env + // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically) + // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility) + // + // Priority: credentials file (UI settings) -> environment variable + // Note: Only auth and endpoint vars are passed. Model mappings and traffic + // control are NOT passed (those require a profile for explicit configuration). + if (credentials?.apiKeys?.anthropic) { + env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic; + } else if (process.env.ANTHROPIC_API_KEY) { + env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY; + } + // If using Claude Max plan via CLI auth, the SDK handles auth automatically + // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here + // unless it was explicitly set in process.env (rare edge case). + if (process.env.ANTHROPIC_AUTH_TOKEN) { + env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN; + } + // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility) + if (process.env.ANTHROPIC_BASE_URL) { + env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL; + } + } + + // Always add system vars from process.env + for (const key of SYSTEM_ENV_VARS) { if (process.env[key]) { env[key] = process.env[key]; } } + return env; } @@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider { conversationHistory, sdkSessionId, thinkingLevel, + claudeApiProfile, + credentials, } = options; // Convert thinking level to token budget @@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, // Pass only explicitly allowed environment variables to SDK - env: buildEnv(), + // When a profile is active, uses profile settings (clean switch) + // When no profile, uses direct Anthropic API (from process.env or CLI OAuth) + env: buildEnv(claudeApiProfile, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 6babb978..0fd8f851 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -25,7 +25,6 @@ import type { InstallationStatus, ContentBlock, } from '@automaker/types'; -import { stripProviderPrefix } from '@automaker/types'; import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; @@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider { args.push('--format', 'json'); // Handle model selection - // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' + // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx) + // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model') if (options.model) { - const model = stripProviderPrefix(options.model); - args.push('--model', model); + // Strip opencode- prefix if present, then ensure slash format + const model = options.model.startsWith('opencode-') + ? options.model.slice('opencode-'.length) + : options.model; + + // If model has slash, it's already provider/model format; otherwise prepend opencode/ + const cliModel = model.includes('/') ? model : `opencode/${model}`; + + args.push('--model', cliModel); } // Note: OpenCode reads from stdin automatically when input is piped diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 5882b96f..6ffbed0f 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -20,6 +20,8 @@ import type { ContentBlock, ThinkingLevel, ReasoningEffort, + ClaudeApiProfile, + Credentials, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; @@ -54,6 +56,10 @@ export interface SimpleQueryOptions { readOnly?: boolean; /** Setting sources for CLAUDE.md loading */ settingSources?: Array<'user' | 'project' | 'local'>; + /** Active Claude API profile for alternative endpoint configuration */ + claudeApiProfile?: ClaudeApiProfile; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */ + credentials?: Credentials; } /** @@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { logger.debug(`Feature text block received (${text.length} chars)`); events.emit('spec-regeneration:event', { diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4fa3d11a..0de21cf5 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('SpecRegeneration'); @@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`; logger.info('Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecRegeneration]', + projectPath + ); + let responseText = ''; let structuredOutput: SpecOutput | null = null; @@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Spec generation only reads code, we write the spec ourselves settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index 98352855..aabeebf2 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver'; import { streamingQuery } from '../../providers/simple-query-service.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { extractImplementedFeatures, @@ -157,6 +160,13 @@ export async function syncSpec( settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecSync]', + projectPath + ); + // Use AI to analyze tech stack const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. @@ -185,6 +195,8 @@ Return ONLY this JSON format, no other text: thinkingLevel, readOnly: true, settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource onText: (text) => { logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); }, diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts index 405a31b2..3ace816d 100644 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -12,8 +12,9 @@ const logger = createLogger('AutoMode'); export function createStartHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, maxConcurrency } = req.body as { + const { projectPath, branchName, maxConcurrency } = req.body as { projectPath: string; + branchName?: string | null; maxConcurrency?: number; }; @@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) { return; } + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + // Check if already running - if (autoModeService.isAutoLoopRunningForProject(projectPath)) { + if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ success: true, - message: 'Auto mode is already running for this project', + message: `Auto mode is already running for ${worktreeDesc}`, alreadyRunning: true, + branchName: normalizedBranchName, }); return; } - // Start the auto loop for this project - await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3); + // Start the auto loop for this project/worktree + const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject( + projectPath, + normalizedBranchName, + maxConcurrency + ); logger.info( - `Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}` + `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); res.json({ success: true, - message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`, + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, + branchName: normalizedBranchName, }); } catch (error) { logError(error, 'Start auto mode failed'); diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index a2ccd832..73c77945 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js'; export function createStatusHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath } = req.body as { projectPath?: string }; + const { projectPath, branchName } = req.body as { + projectPath?: string; + branchName?: string | null; + }; - // If projectPath is provided, return per-project status + // If projectPath is provided, return per-project/worktree status if (projectPath) { - const projectStatus = autoModeService.getStatusForProject(projectPath); + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const projectStatus = autoModeService.getStatusForProject( + projectPath, + normalizedBranchName + ); res.json({ success: true, isRunning: projectStatus.runningCount > 0, @@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) { runningCount: projectStatus.runningCount, maxConcurrency: projectStatus.maxConcurrency, projectPath, + branchName: normalizedBranchName, }); return; } @@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) { // Fall back to global status for backward compatibility const status = autoModeService.getStatus(); const activeProjects = autoModeService.getActiveAutoLoopProjects(); + const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees(); res.json({ success: true, ...status, activeAutoLoopProjects: activeProjects, + activeAutoLoopWorktrees: activeWorktrees, }); } catch (error) { logError(error, 'Get status failed'); diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts index 79f074a8..b3c2fd52 100644 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -12,8 +12,9 @@ const logger = createLogger('AutoMode'); export function createStopHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath } = req.body as { + const { projectPath, branchName } = req.body as { projectPath: string; + branchName?: string | null; }; if (!projectPath) { @@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) { return; } + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + // Check if running - if (!autoModeService.isAutoLoopRunningForProject(projectPath)) { + if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ success: true, - message: 'Auto mode is not running for this project', + message: `Auto mode is not running for ${worktreeDesc}`, wasRunning: false, + branchName: normalizedBranchName, }); return; } - // Stop the auto loop for this project - const runningCount = await autoModeService.stopAutoLoopForProject(projectPath); + // Stop the auto loop for this project/worktree + const runningCount = await autoModeService.stopAutoLoopForProject( + projectPath, + normalizedBranchName + ); logger.info( - `Stopped auto loop for project: ${projectPath}, ${runningCount} features still running` + `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running` ); res.json({ success: true, message: 'Auto mode stopped', runningFeaturesCount: runningCount, + branchName: normalizedBranchName, }); } catch (error) { logError(error, 'Stop auto mode failed'); diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index e96ce8ea..04dc3b57 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -25,7 +25,11 @@ import { saveBacklogPlan, } from './common.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const featureLoader = new FeatureLoader(); @@ -161,6 +165,13 @@ ${userPrompt}`; finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[BacklogPlan]', + projectPath + ); + // Execute the query const stream = provider.executeQuery({ prompt: finalPrompt, @@ -173,6 +184,8 @@ ${userPrompt}`; settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, readOnly: true, // Plan generation only generates text, doesn't write files thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let responseText = ''; diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 5b1fc6ca..0fd3c349 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -165,6 +166,13 @@ ${contentToAnalyze}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeFile]', + cwd + ); + // Use simpleQuery - provider abstraction handles routing to correct provider const result = await simpleQuery({ prompt, @@ -175,6 +183,8 @@ ${contentToAnalyze}`; thinkingLevel, readOnly: true, // File description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const description = result.text; diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 70f9f7dc..0c05bc2a 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeImage'); @@ -284,6 +285,13 @@ export function createDescribeImageHandler( // Get customized prompts from settings const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeImage]', + cwd + ); + // Build the instruction text from centralized prompts const instructionText = prompts.contextDescription.describeImagePrompt; @@ -325,6 +333,8 @@ export function createDescribeImageHandler( thinkingLevel, readOnly: true, // Image description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 5861b418..2fe0f669 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { buildUserPrompt, isValidEnhancementMode, @@ -33,6 +36,8 @@ interface EnhanceRequestBody { model?: string; /** Optional thinking level for Claude models */ thinkingLevel?: ThinkingLevel; + /** Optional project path for per-project Claude API profile */ + projectPath?: string; } /** @@ -62,7 +67,7 @@ export function createEnhanceHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { originalText, enhancementMode, model, thinkingLevel } = + const { originalText, enhancementMode, model, thinkingLevel, projectPath } = req.body as EnhanceRequestBody; // Validate required fields @@ -126,6 +131,14 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[EnhancePrompt]', + projectPath + ); + // Use simpleQuery - provider abstraction handles routing to correct provider // The system prompt is combined with user prompt since some providers // don't have a separate system prompt concept @@ -137,6 +150,8 @@ export function createEnhanceHandler( allowedTools: [], thinkingLevel, readOnly: true, // Prompt enhancement only generates text, doesn't write files + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const enhancedText = result.text; diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index e7603eb8..d6519940 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateTitle'); interface GenerateTitleRequestBody { description: string; + projectPath?: string; } interface GenerateTitleSuccessResponse { @@ -33,7 +37,7 @@ export function createGenerateTitleHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description } = req.body as GenerateTitleRequestBody; + const { description, projectPath } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { @@ -60,6 +64,14 @@ export function createGenerateTitleHandler( const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); const systemPrompt = prompts.titleGeneration.systemPrompt; + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateTitle]', + projectPath + ); + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; // Use simpleQuery - provider abstraction handles all the streaming/extraction @@ -69,6 +81,8 @@ export function createGenerateTitleHandler( cwd: process.cwd(), maxTurns: 1, allowedTools: [], + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const title = result.text; diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index e7d83d99..699b7e46 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -34,7 +34,11 @@ import { ValidationComment, ValidationLinkedPR, } from './validation-schema.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, clearValidationStatus, @@ -43,7 +47,6 @@ import { logger, } from './validation-common.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; /** * Request body for issue validation @@ -166,6 +169,13 @@ ${basePrompt}`; logger.info(`Using model: ${model}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[IssueValidation]', + projectPath + ); + // Use streamingQuery with event callbacks const result = await streamingQuery({ prompt: finalPrompt, @@ -177,6 +187,8 @@ ${basePrompt}`; reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 08a3628b..7a21af6f 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('Suggestions'); @@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`; logger.info('[Suggestions] Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[Suggestions]', + projectPath + ); + let responseText = ''; // Determine if we should use structured output (Claude supports it, Cursor doesn't) @@ -223,6 +234,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Suggestions only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 854e5c60..d4358b65 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -48,6 +48,7 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import { createDiscardChangesHandler } from './routes/discard-changes.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -148,5 +149,13 @@ export function createWorktreeRoutes( createRunInitScriptHandler(events) ); + // Discard changes route + router.post( + '/discard-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createDiscardChangesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 75f43d7f..314fa8ce 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -39,7 +39,10 @@ export function createDiffsHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { // Check if worktree exists diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts new file mode 100644 index 00000000..4f15e053 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -0,0 +1,112 @@ +/** + * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * + * This performs a destructive operation that: + * 1. Resets staged changes (git reset HEAD) + * 2. Discards modified tracked files (git checkout .) + * 3. Removes untracked files and directories (git clean -fd) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createDiscardChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Check for uncommitted changes first + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (!status.trim()) { + res.json({ + success: true, + result: { + discarded: false, + message: 'No changes to discard', + }, + }); + return; + } + + // Count the files that will be affected + const lines = status.trim().split('\n').filter(Boolean); + const fileCount = lines.length; + + // Get branch name before discarding + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const branchName = branchOutput.trim(); + + // Discard all changes: + // 1. Reset any staged changes + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there's nothing staged + }); + + // 2. Discard changes in tracked files + await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no tracked changes + }); + + // 3. Remove untracked files and directories + await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no untracked files + }); + + // Verify all changes were discarded + const { stdout: finalStatus } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (finalStatus.trim()) { + // Some changes couldn't be discarded (possibly ignored files or permission issues) + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + }, + }); + } + } catch (error) { + logError(error, 'Discard changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 4d29eb26..f3d4ed1a 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -37,7 +37,10 @@ export function createFileDiffHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index a450659f..1b504f6f 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -10,7 +10,6 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; @@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getErrorMessage, logError } from '../common.js'; +import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); const execAsync = promisify(exec); @@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse { error: string; } -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - export function createGenerateCommitMessageHandler( settingsService?: SettingsService ): (req: Request, res: Response) => Promise { @@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler( // Get the effective system prompt (custom or default) const systemPrompt = await getSystemPrompt(settingsService); - let message: string; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateCommitMessage]', + worktreePath + ); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${model}`); + // Get provider for the model type + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); - const provider = ProviderFactory.getProviderForModel(model); - const bareModel = stripProviderPrefix(model); + // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation + const effectivePrompt = isCursorModel(model) + ? `${systemPrompt}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; - const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + logger.info(`Using ${provider.getName()} provider for model: ${model}`); - let responseText = ''; - const cursorStream = provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: worktreePath, - maxTurns: 1, - allowedTools: [], - readOnly: true, - }); + let responseText = ''; + const stream = provider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); - // Wrap with timeout to prevent indefinite hangs - for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; } } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if available (some providers return final text here) + responseText = msg.result; } - - message = responseText.trim(); - } else { - // Use Claude SDK for Claude models - const stream = query({ - prompt: userPrompt, - options: { - model, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'default', - }, - }); - - // Wrap with timeout to prevent indefinite hangs - message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); } + const message = responseText.trim(); + if (!message || message.trim().length === 0) { logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index 3d512452..5c2eb808 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -28,7 +28,10 @@ export function createInfoHandler() { } // Check if worktree exists (git worktrees are stored in project directory) - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index f9d6bf88..b44c5ae4 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -28,7 +28,10 @@ export function createStatusHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 359719d3..0b89a2bd 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -29,6 +29,7 @@ import { getSkillsConfiguration, getSubagentsConfiguration, getCustomSubagents, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; interface Message { @@ -274,6 +275,13 @@ export class AgentService { ? await getCustomSubagents(this.settingsService, effectiveWorkDir) : undefined; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AgentService]', + effectiveWorkDir + ); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -378,6 +386,8 @@ export class AgentService { agents: customSubagents, // Pass custom subagents for task delegation thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 59af7872..28498829 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -21,7 +21,12 @@ import type { ThinkingLevel, PlanningMode, } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + DEFAULT_MAX_CONCURRENCY, + isClaudeModel, + stripProviderPrefix, +} from '@automaker/types'; import { buildPromptWithImages, classifyError, @@ -63,6 +68,7 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; import { getNotificationService } from './notification-service.js'; @@ -233,10 +239,20 @@ interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; projectPath: string; + branchName: string | null; // null = main worktree } /** - * Per-project autoloop state for multi-project support + * Generate a unique key for worktree-scoped auto loop state + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ +function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { + return `${projectPath}::${branchName ?? '__main__'}`; +} + +/** + * Per-worktree autoloop state for multi-project/worktree support */ interface ProjectAutoLoopState { abortController: AbortController; @@ -244,6 +260,8 @@ interface ProjectAutoLoopState { isRunning: boolean; consecutiveFailures: { timestamp: number; error: string }[]; pausedDueToFailures: boolean; + hasEmittedIdleEvent: boolean; + branchName: string | null; // null = main worktree } /** @@ -255,6 +273,7 @@ interface ExecutionState { autoLoopWasRunning: boolean; maxConcurrency: number; projectPath: string; + branchName: string | null; // null = main worktree runningFeatureIds: string[]; savedAt: string; } @@ -263,8 +282,9 @@ interface ExecutionState { const DEFAULT_EXECUTION_STATE: ExecutionState = { version: 1, autoLoopWasRunning: false, - maxConcurrency: 3, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, projectPath: '', + branchName: null, runningFeatureIds: [], savedAt: '', }; @@ -289,6 +309,8 @@ export class AutoModeService { // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) private consecutiveFailures: { timestamp: number; error: string }[] = []; private pausedDueToFailures = false; + // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) + private hasEmittedIdleEvent = false; constructor(events: EventEmitter, settingsService?: SettingsService) { this.events = events; @@ -472,24 +494,81 @@ export class AutoModeService { this.consecutiveFailures = []; } - /** - * Start the auto mode loop for a specific project (supports multiple concurrent projects) - * @param projectPath - The project to start auto mode for - * @param maxConcurrency - Maximum concurrent features (default: 3) - */ - async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise { - // Check if this project already has an active autoloop - const existingState = this.autoLoopsByProject.get(projectPath); - if (existingState?.isRunning) { - throw new Error(`Auto mode is already running for project: ${projectPath}`); + private async resolveMaxConcurrency( + projectPath: string, + branchName: string | null, + provided?: number + ): Promise { + if (typeof provided === 'number' && Number.isFinite(provided)) { + return provided; } - // Create new project autoloop state + if (!this.settingsService) { + return DEFAULT_MAX_CONCURRENCY; + } + + try { + const settings = await this.settingsService.getGlobalSettings(); + const globalMax = + typeof settings.maxConcurrency === 'number' + ? settings.maxConcurrency + : DEFAULT_MAX_CONCURRENCY; + const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; + const autoModeByWorktree = (settings as unknown as Record) + .autoModeByWorktree; + + if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { + const key = `${projectId}::${branchName ?? '__main__'}`; + const entry = (autoModeByWorktree as Record)[key] as + | { maxConcurrency?: number } + | undefined; + if (entry && typeof entry.maxConcurrency === 'number') { + return entry.maxConcurrency; + } + } + + return globalMax; + } catch { + return DEFAULT_MAX_CONCURRENCY; + } + } + + /** + * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) + * @param projectPath - The project to start auto mode for + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) + */ + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const resolvedMaxConcurrency = await this.resolveMaxConcurrency( + projectPath, + branchName, + maxConcurrency + ); + + // Use worktree-scoped key + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + + // Check if this project/worktree already has an active autoloop + const existingState = this.autoLoopsByProject.get(worktreeKey); + if (existingState?.isRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + throw new Error( + `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` + ); + } + + // Create new project/worktree autoloop state const abortController = new AbortController(); const config: AutoModeConfig = { - maxConcurrency, + maxConcurrency: resolvedMaxConcurrency, useWorktrees: true, projectPath, + branchName, }; const projectState: ProjectAutoLoopState = { @@ -498,56 +577,68 @@ export class AutoModeService { isRunning: true, consecutiveFailures: [], pausedDueToFailures: false, + hasEmittedIdleEvent: false, + branchName, }; - this.autoLoopsByProject.set(projectPath, projectState); + this.autoLoopsByProject.set(worktreeKey, projectState); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}` + `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); this.emitAutoModeEvent('auto_mode_started', { - message: `Auto mode started with max ${maxConcurrency} concurrent features`, + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, projectPath, + branchName, }); // Save execution state for recovery after restart - await this.saveExecutionStateForProject(projectPath, maxConcurrency); + await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency); // Run the loop in the background - this.runAutoLoopForProject(projectPath).catch((error) => { - logger.error(`Loop error for ${projectPath}:`, error); + this.runAutoLoopForProject(worktreeKey).catch((error) => { + const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error); const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, projectPath, + branchName, }); }); + + return resolvedMaxConcurrency; } /** - * Run the auto loop for a specific project + * Run the auto loop for a specific project/worktree + * @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__) */ - private async runAutoLoopForProject(projectPath: string): Promise { - const projectState = this.autoLoopsByProject.get(projectPath); + private async runAutoLoopForProject(worktreeKey: string): Promise { + const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { - logger.warn(`No project state found for ${projectPath}, stopping loop`); + logger.warn(`No project state found for ${worktreeKey}, stopping loop`); return; } + const { projectPath, branchName } = projectState.config; + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info( - `[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` + `[AutoLoop] Starting loop for ${worktreeDesc} in ${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); + // Count running features for THIS project/worktree only + const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName); - // Check if we have capacity for this project + // Check if we have capacity for this project/worktree if (projectRunningCount >= projectState.config.maxConcurrency) { logger.debug( `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` @@ -556,19 +647,32 @@ export class AutoModeService { continue; } - // Load pending features for this project - const pendingFeatures = await this.loadPendingFeatures(projectPath); + // Load pending features for this project/worktree + const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName); - logger.debug( - `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running` + logger.info( + `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}` ); 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...`); + // Emit idle event only once when backlog is empty AND no features are running + if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) { + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`); + } else if (projectRunningCount > 0) { + logger.info( + `[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...` + ); + } else { + logger.warn( + `[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.` + ); + } await this.sleep(10000); continue; } @@ -578,6 +682,8 @@ export class AutoModeService { if (nextFeature) { logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); + // Reset idle event flag since we're doing work again + projectState.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( projectPath, @@ -619,13 +725,47 @@ export class AutoModeService { } /** - * Stop the auto mode loop for a specific project - * @param projectPath - The project to stop auto mode for + * Get count of running features for a specific worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree (features without branchName or with "main") */ - async stopAutoLoopForProject(projectPath: string): Promise { - const projectState = this.autoLoopsByProject.get(projectPath); + private getRunningCountForWorktree(projectPath: string, branchName: string | null): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + // Filter by project path AND branchName to get accurate worktree-specific count + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: match features with branchName === null OR branchName === "main" + if ( + feature.projectPath === projectPath && + (featureBranch === null || featureBranch === 'main') + ) { + count++; + } + } else { + // Feature worktree: exact match + if (feature.projectPath === projectPath && featureBranch === branchName) { + count++; + } + } + } + return count; + } + + /** + * Stop the auto mode loop for a specific project/worktree + * @param projectPath - The project to stop auto mode for + * @param branchName - The branch name, or null for main worktree + */ + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { - logger.warn(`No auto loop running for project: ${projectPath}`); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`); return 0; } @@ -634,43 +774,57 @@ export class AutoModeService { projectState.abortController.abort(); // Clear execution state when auto-loop is explicitly stopped - await this.clearExecutionState(projectPath); + await this.clearExecutionState(projectPath, branchName); // Emit stop event if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', projectPath, + branchName, }); } // Remove from map - this.autoLoopsByProject.delete(projectPath); + this.autoLoopsByProject.delete(worktreeKey); - return this.getRunningCountForProject(projectPath); + return this.getRunningCountForWorktree(projectPath, branchName); } /** - * Check if auto mode is running for a specific project + * Check if auto mode is running for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - isAutoLoopRunningForProject(projectPath: string): boolean { - const projectState = this.autoLoopsByProject.get(projectPath); + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.isRunning ?? false; } /** - * Get auto loop config for a specific project + * Get auto loop config for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null { - const projectState = this.autoLoopsByProject.get(projectPath); + getAutoLoopConfigForProject( + projectPath: string, + branchName: string | null = null + ): AutoModeConfig | null { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.config ?? null; } /** - * Save execution state for a specific project + * Save execution state for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * @param maxConcurrency - Maximum concurrent features */ private async saveExecutionStateForProject( projectPath: string, + branchName: string | null, maxConcurrency: number ): Promise { try { @@ -685,15 +839,18 @@ export class AutoModeService { autoLoopWasRunning: true, maxConcurrency, projectPath, + branchName, runningFeatureIds, savedAt: new Date().toISOString(), }; await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features` + `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features` ); } catch (error) { - logger.error(`Failed to save execution state for ${projectPath}:`, error); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error); } } @@ -701,7 +858,10 @@ export class AutoModeService { * 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 { + async startAutoLoop( + projectPath: string, + maxConcurrency = DEFAULT_MAX_CONCURRENCY + ): Promise { // For backward compatibility, delegate to the new per-project method // But also maintain legacy state for existing code that might check it if (this.autoLoopRunning) { @@ -717,6 +877,7 @@ export class AutoModeService { maxConcurrency, useWorktrees: true, projectPath, + branchName: null, }; this.emitAutoModeEvent('auto_mode_started', { @@ -752,7 +913,7 @@ export class AutoModeService { ) { try { // Check if we have capacity - if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { + if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { await this.sleep(5000); continue; } @@ -761,10 +922,22 @@ export class AutoModeService { const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath: this.config!.projectPath, - }); + // Emit idle event only once when backlog is empty AND no features are running + const runningCount = this.runningFeatures.size; + if (runningCount === 0 && !this.hasEmittedIdleEvent) { + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: this.config!.projectPath, + }); + this.hasEmittedIdleEvent = true; + logger.info(`[AutoLoop] Backlog complete, auto mode now idle`); + } else if (runningCount > 0) { + logger.debug( + `[AutoLoop] No pending features, ${runningCount} still running, waiting...` + ); + } else { + logger.debug(`[AutoLoop] No pending features, waiting for new items...`); + } await this.sleep(10000); continue; } @@ -773,6 +946,8 @@ export class AutoModeService { const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); if (nextFeature) { + // Reset idle event flag since we're doing work again + this.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( this.config!.projectPath, @@ -862,6 +1037,9 @@ export class AutoModeService { await this.saveExecutionState(projectPath); } + // Declare feature outside try block so it's available in catch for error reporting + let feature: Awaited> | null = null; + try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); @@ -880,18 +1058,8 @@ export class AutoModeService { } } - // Emit feature start event early - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - feature: { - id: featureId, - title: 'Loading...', - description: 'Feature is starting', - }, - }); // Load feature details FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); + feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } @@ -924,9 +1092,22 @@ export class AutoModeService { tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.branchName = branchName ?? null; - // Update feature status to in_progress + // Update feature status to in_progress BEFORE emitting event + // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + // Emit feature start event AFTER status update so frontend sees correct status + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: feature.branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Loading...', + description: feature.description || 'Feature is starting', + }, + }); + // Load autoLoadClaudeMd setting to determine context loading strategy const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, @@ -1070,6 +1251,8 @@ export class AutoModeService { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: `Feature completed in ${Math.round( (Date.now() - tempRunningFeature.startTime) / 1000 @@ -1084,6 +1267,8 @@ export class AutoModeService { if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: false, message: 'Feature stopped by user', projectPath, @@ -1093,6 +1278,8 @@ export class AutoModeService { await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1413,6 +1600,8 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: 'Pipeline step no longer exists - feature completed without remaining pipeline steps', @@ -1526,6 +1715,7 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, + branchName: branchName ?? null, feature: { id: featureId, title: feature.title || 'Resuming Pipeline', @@ -1535,8 +1725,9 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_progress', { featureId, - content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, projectPath, + branchName: branchName ?? null, + content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, }); // Load autoLoadClaudeMd setting @@ -1565,6 +1756,8 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: 'Pipeline resumed and completed successfully', projectPath, @@ -1575,6 +1768,8 @@ Complete the pipeline step instructions above. Review the previous work and appl if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: false, message: 'Pipeline resume stopped by user', projectPath, @@ -1584,6 +1779,8 @@ Complete the pipeline step instructions above. Review the previous work and appl await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1705,22 +1902,25 @@ Address the follow-up instructions above. Review the previous work and make the provider, }); - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - feature: feature || { - id: featureId, - title: 'Follow-up', - description: prompt.substring(0, 100), - }, - model, - provider, - }); - try { - // Update feature status to in_progress + // Update feature status to in_progress BEFORE emitting event + // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + // Emit feature start event AFTER status update so frontend sees correct status + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName, + feature: feature || { + id: featureId, + title: 'Follow-up', + description: prompt.substring(0, 100), + }, + model, + provider, + }); + // Copy follow-up images to feature folder const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { @@ -1814,6 +2014,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: branchName ?? null, passes: true, message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, @@ -1825,6 +2027,8 @@ Address the follow-up instructions above. Review the previous work and make the if (!errorInfo.isCancellation) { this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature?.title, + branchName: branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1852,8 +2056,13 @@ Address the follow-up instructions above. Review the previous work and make the * Verify a feature's implementation */ async verifyFeature(projectPath: string, featureId: string): Promise { + // Load feature to get the name for event reporting + const feature = await this.loadFeature(projectPath, featureId); + // Worktrees are in project dir - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); let workDir = projectPath; try { @@ -1898,6 +2107,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: allPassed, message: allPassed ? 'All verification checks passed' @@ -1934,7 +2145,9 @@ Address the follow-up instructions above. Review the previous work and make the } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(legacyWorktreePath); workDir = legacyWorktreePath; @@ -1974,6 +2187,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: true, message: `Changes committed: ${hash.trim().substring(0, 8)}`, projectPath, @@ -2012,6 +2227,7 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_start', { featureId: analysisFeatureId, projectPath, + branchName: null, // Project analysis is not worktree-specific feature: { id: analysisFeatureId, title: 'Project Analysis', @@ -2057,6 +2273,13 @@ Format your response as a structured markdown document.`; thinkingLevel: analysisThinkingLevel, }); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + projectPath + ); + const options: ExecuteOptions = { prompt, model: sdkOptions.model ?? analysisModel, @@ -2066,6 +2289,8 @@ Format your response as a structured markdown document.`; abortController, settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(options); @@ -2096,6 +2321,8 @@ Format your response as a structured markdown document.`; this.emitAutoModeEvent('auto_mode_feature_complete', { featureId: analysisFeatureId, + featureName: 'Project Analysis', + branchName: null, // Project analysis is not worktree-specific passes: true, message: 'Project analysis completed', projectPath, @@ -2104,6 +2331,8 @@ Format your response as a structured markdown document.`; const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { featureId: analysisFeatureId, + featureName: 'Project Analysis', + branchName: null, // Project analysis is not worktree-specific error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -2127,20 +2356,27 @@ Format your response as a structured markdown document.`; } /** - * Get status for a specific project - * @param projectPath - The project to get status for + * Get status for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - getStatusForProject(projectPath: string): { + getStatusForProject( + projectPath: string, + branchName: string | null = null + ): { isAutoLoopRunning: boolean; runningFeatures: string[]; runningCount: number; maxConcurrency: number; + branchName: string | null; } { - const projectState = this.autoLoopsByProject.get(projectPath); + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); const runningFeatures: string[] = []; for (const [featureId, feature] of this.runningFeatures) { - if (feature.projectPath === projectPath) { + // Filter by project path AND branchName to get worktree-specific features + if (feature.projectPath === projectPath && feature.branchName === branchName) { runningFeatures.push(featureId); } } @@ -2149,21 +2385,39 @@ Format your response as a structured markdown document.`; isAutoLoopRunning: projectState?.isRunning ?? false, runningFeatures, runningCount: runningFeatures.length, - maxConcurrency: projectState?.config.maxConcurrency ?? 3, + maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName, }; } /** - * Get all projects that have auto mode running + * Get all active auto loop worktrees with their project paths and branch names */ - getActiveAutoLoopProjects(): string[] { - const activeProjects: string[] = []; - for (const [projectPath, state] of this.autoLoopsByProject) { + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; + for (const [, state] of this.autoLoopsByProject) { if (state.isRunning) { - activeProjects.push(projectPath); + activeWorktrees.push({ + projectPath: state.config.projectPath, + branchName: state.branchName, + }); } } - return activeProjects; + return activeWorktrees; + } + + /** + * Get all projects that have auto mode running (legacy, returns unique project paths) + * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information + */ + getActiveAutoLoopProjects(): string[] { + const activeProjects = new Set(); + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeProjects.add(state.config.projectPath); + } + } + return Array.from(activeProjects); } /** @@ -2179,22 +2433,25 @@ Format your response as a structured markdown document.`; provider?: ModelProvider; title?: string; description?: string; + branchName?: string; }> > { const agents = await Promise.all( Array.from(this.runningFeatures.values()).map(async (rf) => { - // Try to fetch feature data to get title and description + // Try to fetch feature data to get title, description, and branchName let title: string | undefined; let description: string | undefined; + let branchName: string | undefined; try { const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); if (feature) { title = feature.title; description = feature.description; + branchName = feature.branchName; } } catch (error) { - // Silently ignore errors - title/description are optional + // Silently ignore errors - title/description/branchName are optional } return { @@ -2206,6 +2463,7 @@ Format your response as a structured markdown document.`; provider: rf.provider, title, description, + branchName, }; }) ); @@ -2600,7 +2858,15 @@ Format your response as a structured markdown document.`; } } - private async loadPendingFeatures(projectPath: string): Promise { + /** + * Load pending features for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name to filter by, or null for main worktree (features without branchName) + */ + private async loadPendingFeatures( + projectPath: string, + branchName: string | null = null + ): Promise { // Features are stored in .automaker directory const featuresDir = getFeaturesDir(projectPath); @@ -2632,21 +2898,60 @@ Format your response as a structured markdown document.`; allFeatures.push(feature); - // Track pending features separately + // Track pending features separately, filtered by worktree/branch if ( feature.status === 'pending' || feature.status === 'ready' || feature.status === 'backlog' ) { - pendingFeatures.push(feature); + // Filter by branchName: + // - If branchName is null (main worktree), include features with branchName === null OR branchName === "main" + // - If branchName is set, only include features with matching branchName + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: include features without branchName OR with branchName === "main" + // This handles both correct (null) and legacy ("main") cases + if (featureBranch === null || featureBranch === 'main') { + pendingFeatures.push(feature); + } else { + logger.debug( + `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree` + ); + } + } else { + // Feature worktree: include features with matching branchName + if (featureBranch === branchName) { + pendingFeatures.push(feature); + } else { + logger.debug( + `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}` + ); + } + } } } } - logger.debug( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status` + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info( + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}` ); + if (pendingFeatures.length === 0) { + logger.warn( + `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` + ); + // Log all backlog features to help debug branchName matching + const allBacklogFeatures = allFeatures.filter( + (f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' + ); + if (allBacklogFeatures.length > 0) { + logger.info( + `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}` + ); + } + } + // Apply dependency-aware ordering const { orderedFeatures } = resolveDependencies(pendingFeatures); @@ -2655,11 +2960,41 @@ Format your response as a structured markdown document.`; const skipVerification = settings?.skipVerificationInAutoMode ?? false; // Filter to only features with satisfied dependencies - const readyFeatures = orderedFeatures.filter((feature: Feature) => - areDependenciesSatisfied(feature, allFeatures, { skipVerification }) - ); + const readyFeatures: Feature[] = []; + const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; - logger.debug( + for (const feature of orderedFeatures) { + const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); + if (isSatisfied) { + readyFeatures.push(feature); + } else { + // Find which dependencies are blocking + const blockingDeps = + feature.dependencies?.filter((depId) => { + const dep = allFeatures.find((f) => f.id === depId); + if (!dep) return true; // Missing dependency + if (skipVerification) { + return dep.status === 'running'; + } + return dep.status !== 'completed' && dep.status !== 'verified'; + }) || []; + blockedFeatures.push({ + feature, + reason: + blockingDeps.length > 0 + ? `Blocked by dependencies: ${blockingDeps.join(', ')}` + : 'Unknown dependency issue', + }); + } + } + + if (blockedFeatures.length > 0) { + logger.info( + `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}` + ); + } + + logger.info( `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` ); @@ -2934,6 +3269,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + finalProjectPath + ); + const executeOptions: ExecuteOptions = { prompt: promptContent, model: bareModel, @@ -2945,6 +3287,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Execute via provider @@ -3247,6 +3591,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let revisionText = ''; @@ -3392,6 +3738,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let taskOutput = ''; @@ -3486,6 +3834,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); for await (const msg of continuationStream) { @@ -3818,8 +4168,9 @@ After generating the revised spec, output: const state: ExecutionState = { version: 1, autoLoopWasRunning: this.autoLoopRunning, - maxConcurrency: this.config?.maxConcurrency ?? 3, + maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, projectPath, + branchName: null, // Legacy global auto mode uses main worktree runningFeatureIds: Array.from(this.runningFeatures.keys()), savedAt: new Date().toISOString(), }; @@ -3850,11 +4201,15 @@ After generating the revised spec, output: /** * Clear execution state (called on successful shutdown or when auto-loop stops) */ - private async clearExecutionState(projectPath: string): Promise { + private async clearExecutionState( + projectPath: string, + branchName: string | null = null + ): Promise { try { const statePath = getExecutionStatePath(projectPath); await secureFs.unlink(statePath); - logger.info('Cleared execution state'); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`Cleared execution state for ${worktreeDesc}`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error('Failed to clear execution state:', error); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aebed98b..aa8afc1c 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -468,10 +468,41 @@ export class ClaudeUsageService { /** * Strip ANSI escape codes from text + * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { + // First strip ANSI sequences (colors, etc) and handle CR // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + let clean = text + // CSI sequences: ESC [ ... (letter or @) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') + // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') + // Other ESC sequences: ESC (letter) + .replace(/\x1B[A-Za-z]/g, '') + // Carriage returns: replace with newline to avoid concatenation + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + // Handle backspaces (\x08) by applying them + // If we encounter a backspace, remove the character before it + while (clean.includes('\x08')) { + clean = clean.replace(/[^\x08]\x08/, ''); + clean = clean.replace(/^\x08+/, ''); + } + + // Explicitly strip known "Synchronized Output" and "Window Title" garbage + // even if ESC is missing (seen in some environments) + clean = clean + .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l + .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL + .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence) + + // Strip remaining non-printable control characters (except newline \n) + // ASCII 0-8, 11-31, 127 + clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ''); + + return clean; } /** @@ -550,7 +581,7 @@ export class ClaudeUsageService { sectionLabel: string, type: string ): { percentage: number; resetTime: string; resetText: string } { - let percentage = 0; + let percentage: number | null = null; let resetTime = this.getDefaultResetTime(type); let resetText = ''; @@ -564,7 +595,7 @@ export class ClaudeUsageService { } if (sectionIndex === -1) { - return { percentage, resetTime, resetText }; + return { percentage: 0, resetTime, resetText }; } // Look at the lines following the section header (within a window of 5 lines) @@ -572,7 +603,8 @@ export class ClaudeUsageService { for (const line of searchWindow) { // Extract percentage - only take the first match (avoid picking up next section's data) - if (percentage === 0) { + // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used) + if (percentage === null) { const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); if (percentMatch) { const value = parseInt(percentMatch[1], 10); @@ -584,18 +616,31 @@ export class ClaudeUsageService { // Extract reset time - only take the first match if (!resetText && line.toLowerCase().includes('reset')) { - resetText = line; + // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes + const match = line.match(/(Resets?.*)$/i); + // If regex fails despite 'includes', likely a complex string issues - verify match before using line + // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage + if (match) { + resetText = match[1]; + } } } // Parse the reset time if we found one if (resetText) { + // Clean up resetText: remove percentage info if it was matched on the same line + // e.g. "46%used Resets5:59pm" -> " Resets5:59pm" + resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim(); + + // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm") + resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2'); + resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); } - return { percentage, resetTime, resetText }; + return { percentage: percentage ?? 0, resetTime, resetText }; } /** @@ -624,7 +669,7 @@ export class ClaudeUsageService { } // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" - const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); if (simpleTimeMatch) { let hours = parseInt(simpleTimeMatch[1], 10); const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; @@ -649,8 +694,11 @@ export class ClaudeUsageService { } // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + // The regex explicitly matches only valid 3-letter month abbreviations to avoid + // matching words like "Resets" when there's no space separator. + // Optional "resets\s*" prefix handles cases with or without space after "Resets" const dateMatch = text.match( - /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const monthName = dateMatch[1]; diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 08da71dd..74070b78 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -57,6 +57,7 @@ interface HookContext { interface AutoModeEventPayload { type?: string; featureId?: string; + featureName?: string; passes?: boolean; message?: string; error?: string; @@ -152,6 +153,7 @@ export class EventHookService { // Build context for variable substitution const context: HookContext = { featureId: payload.featureId, + featureName: payload.featureName, projectPath: payload.projectPath, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, error: payload.error || payload.message, diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 4ef3d8a8..bcb469b1 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; import { stripProviderPrefix } from '@automaker/types'; -import { getPromptCustomization } from '../lib/settings-helpers.js'; +import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js'; const logger = createLogger('IdeationService'); @@ -223,6 +223,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: message, model: bareModel, @@ -232,6 +239,8 @@ export class IdeationService { maxTurns: 1, // Single turn for ideation abortController: activeSession.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); @@ -678,6 +687,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: prompt.prompt, model: bareModel, @@ -688,6 +704,8 @@ export class IdeationService { // Disable all tools - we just want text generation, not codebase analysis allowedTools: [], abortController: new AbortController(), + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5b9f81cb..18eafcc3 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -41,7 +41,12 @@ import { CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, } from '../types/settings.js'; -import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types'; +import { + DEFAULT_MAX_CONCURRENCY, + migrateModelId, + migrateCursorModelIds, + migrateOpencodeModelIds, +} from '@automaker/types'; const logger = createLogger('SettingsService'); @@ -166,6 +171,41 @@ export class SettingsService { needsSave = true; } + // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users + // If user has an Anthropic API key in credentials but no profiles, create a + // "Direct Anthropic" profile that references the credentials and set it as active. + if (storedVersion < 5) { + try { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + const directAnthropicProfile = { + id: `profile-${Date.now()}-direct-anthropic`, + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + apiKeySource: 'credentials' as const, + useAuthToken: false, + }; + + result.claudeApiProfiles = [directAnthropicProfile]; + result.activeClaudeApiProfileId = directAnthropicProfile.id; + + logger.info( + 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' + ); + } + } catch (error) { + logger.warn( + 'Migration v4->v5: Could not check credentials for auto-profile creation:', + error + ); + } + needsSave = true; + } + // Update version if any migration occurred if (needsSave) { result.version = SETTINGS_VERSION; @@ -372,6 +412,7 @@ export class SettingsService { ignoreEmptyArrayOverwrite('recentFolders'); ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); + ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Empty object overwrite guard if ( @@ -597,6 +638,17 @@ export class SettingsService { }; } + // Handle activeClaudeApiProfileId special cases: + // - "__USE_GLOBAL__" marker means delete the key (use global setting) + // - null means explicit "Direct Anthropic API" + // - string means specific profile ID + if ( + 'activeClaudeApiProfileId' in updates && + updates.activeClaudeApiProfileId === '__USE_GLOBAL__' + ) { + delete updated.activeClaudeApiProfileId; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); @@ -682,7 +734,7 @@ export class SettingsService { theme: (appState.theme as GlobalSettings['theme']) || 'dark', sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true, chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false, - maxConcurrency: (appState.maxConcurrency as number) || 3, + maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY, defaultSkipTests: appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, enableDependencyBlocking: diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 07ad13c9..7901192c 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => { expect(result).toBe('Plain text'); }); + + it('should strip OSC sequences (window title, etc.)', () => { + const service = new ClaudeUsageService(); + // OSC sequence to set window title: ESC ] 0 ; title BEL + const input = '\x1B]0;Claude Code\x07Regular text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Regular text'); + }); + + it('should strip DEC private mode sequences', () => { + const service = new ClaudeUsageService(); + // DEC private mode sequences like ESC[?2026h and ESC[?2026l + const input = '\x1B[?2026lClaude Code\x1B[?2026h more text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Claude Code more text'); + }); + + it('should handle complex terminal output with mixed escape sequences', () => { + const service = new ClaudeUsageService(); + // Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h" + // This contains OSC (set title) and DEC private mode sequences + const input = + '\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 0%used Resets3am'); + }); + + it('should strip single character escape sequences', () => { + const service = new ClaudeUsageService(); + // ESC c is the reset terminal command + const input = '\x1BcReset text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Reset text'); + }); + + it('should remove control characters but preserve newlines and tabs', () => { + const service = new ClaudeUsageService(); + // BEL character (\x07) should be stripped, but the word "Bell" is regular text + const input = 'Line 1\nLine 2\tTabbed\x07 with bell'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + // BEL is stripped, newlines and tabs preserved + expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); + }); }); describe('parseResetTime', () => { diff --git a/apps/ui/package.json b/apps/ui/package.json index a35db779..e66433fd 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -147,6 +147,7 @@ "productName": "Automaker", "artifactName": "${productName}-${version}-${arch}.${ext}", "npmRebuild": false, + "publish": null, "afterPack": "./scripts/rebuild-server-natives.cjs", "directories": { "output": "release" diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index af63af32..0df4ab8c 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { toast } from 'sonner'; @@ -6,35 +6,67 @@ import { cn } from '@/lib/utils'; import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; -import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + THEME_SUBMENU_CONSTANTS, +} from '@/components/layout/sidebar/constants'; import { useThemePreview } from '@/components/layout/sidebar/hooks'; -// Constant for "use global theme" option +/** + * Constant representing the "use global theme" option. + * An empty string is used to indicate that no project-specific theme is set. + */ const USE_GLOBAL_THEME = '' as const; -// Constants for z-index values +/** + * Z-index values for context menu layering. + * Ensures proper stacking order when menus overlap. + */ const Z_INDEX = { + /** Base z-index for the main context menu */ CONTEXT_MENU: 100, + /** Higher z-index for theme submenu to appear above parent menu */ THEME_SUBMENU: 101, } as const; -// Theme option type - using ThemeMode for type safety +/** + * Represents a selectable theme option in the theme submenu. + * Uses ThemeMode from app-store for type safety. + */ interface ThemeOption { + /** The theme mode value (e.g., 'dark', 'light', 'dracula') */ value: ThemeMode; + /** Display label for the theme option */ label: string; + /** Lucide icon component to display alongside the label */ icon: LucideIcon; + /** CSS color value for the icon */ color: string; } -// Reusable theme button component to avoid duplication (DRY principle) +/** + * Props for the ThemeButton component. + * Defines the interface for rendering individual theme selection buttons. + */ interface ThemeButtonProps { + /** The theme option data to display */ option: ThemeOption; + /** Whether this theme is currently selected */ isSelected: boolean; + /** Handler for pointer enter events (used for preview) */ onPointerEnter: () => void; + /** Handler for pointer leave events (used to clear preview) */ onPointerLeave: (e: React.PointerEvent) => void; + /** Handler for click events (used to select theme) */ onClick: () => void; } +/** + * A reusable button component for individual theme options. + * Implements hover preview and selection functionality. + * Memoized to prevent unnecessary re-renders when parent state changes. + */ const ThemeButton = memo(function ThemeButton({ option, isSelected, @@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({ ); }); -// Reusable theme column component +/** + * Props for the ThemeColumn component. + * Defines the interface for rendering a column of related theme options (e.g., dark or light themes). + */ interface ThemeColumnProps { + /** Column header title (e.g., "Dark", "Light") */ title: string; + /** Icon to display in the column header */ icon: LucideIcon; + /** Array of theme options to display in this column */ themes: ThemeOption[]; + /** Currently selected theme value, or null if using global theme */ selectedTheme: ThemeMode | null; + /** Handler called when user hovers over a theme option for preview */ onPreviewEnter: (value: ThemeMode) => void; + /** Handler called when user stops hovering over a theme option */ onPreviewLeave: (e: React.PointerEvent) => void; + /** Handler called when user clicks to select a theme */ onSelect: (value: ThemeMode) => void; } +/** + * A reusable column component for displaying themed options. + * Renders a group of related themes (e.g., all dark themes or all light themes) + * with a header and scrollable list of ThemeButton components. + * Memoized to prevent unnecessary re-renders. + */ const ThemeColumn = memo(function ThemeColumn({ title, icon: Icon, @@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({ ); }); +/** + * Props for the ProjectContextMenu component. + * Defines the interface for the project right-click context menu. + */ interface ProjectContextMenuProps { + /** The project this context menu is for */ project: Project; + /** Screen coordinates where the context menu should appear */ position: { x: number; y: number }; + /** Callback to close the context menu */ onClose: () => void; + /** Callback when user selects "Edit Name & Icon" option */ onEdit: (project: Project) => void; } +/** + * A context menu component for project-specific actions. + * + * Provides options for: + * - Editing project name and icon + * - Setting project-specific theme (with live preview on hover) + * - Removing project from the workspace + * + * Features viewport-aware positioning for the theme submenu to prevent + * overflow, and implements delayed hover handling to improve UX when + * navigating between the trigger button and submenu. + * + * @param props - Component props + * @returns The rendered context menu or null if not visible + */ export function ProjectContextMenu({ project, position, @@ -130,9 +201,82 @@ export function ProjectContextMenu({ const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); + const closeTimeoutRef = useRef | null>(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Handler to open theme submenu and cancel any pending close + const handleThemeMenuEnter = useCallback(() => { + // Cancel any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setShowThemeSubmenu(true); + }, []); + + // Handler to close theme submenu with a small delay + // This prevents the submenu from closing when mouse crosses the gap between trigger and submenu + const handleThemeMenuLeave = useCallback(() => { + // Add a small delay before closing to allow mouse to reach submenu + closeTimeoutRef.current = setTimeout(() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }, 100); // 100ms delay is enough to cross the gap + }, [setPreviewTheme]); + + /** + * Calculates theme submenu position to prevent viewport overflow. + * + * This memoized calculation determines the optimal vertical position and maximum + * height for the theme submenu based on the current viewport dimensions and + * the trigger button's position. + * + * @returns Object containing: + * - top: Vertical offset from default position (negative values shift submenu up) + * - maxHeight: Maximum height constraint to prevent overflow with scrolling + */ + const submenuPosition = useMemo(() => { + const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } = + THEME_SUBMENU_CONSTANTS; + + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING; + + // If it would overflow, calculate how much to shift it up + if (wouldOverflowBottom) { + // Calculate the offset needed to align submenu bottom with viewport bottom minus padding + const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING); + return { + top: -overflowAmount, + maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + ESTIMATED_SUBMENU_HEIGHT, + viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING + ), + }; + }, [position.y]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { // Don't close if a confirmation dialog is open (dialog is in a portal) @@ -242,11 +386,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} > )} + + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + + {/* Dev Server Logs Panel */} +
); } @@ -308,6 +449,7 @@ export function WorktreePanel({ aheadCount={aheadCount} behindCount={behindCount} gitRepoStatus={gitRepoStatus} + isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)} @@ -319,6 +461,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -330,6 +474,7 @@ export function WorktreePanel({ onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} + onToggleAutoMode={handleToggleAutoMode} hasInitScript={hasInitScript} /> )} @@ -368,6 +513,7 @@ export function WorktreePanel({ aheadCount={aheadCount} behindCount={behindCount} gitRepoStatus={gitRepoStatus} + isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} @@ -379,6 +525,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -390,6 +538,7 @@ export function WorktreePanel({ onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} + onToggleAutoMode={handleToggleAutoMode} hasInitScript={hasInitScript} /> ); @@ -424,6 +573,27 @@ export function WorktreePanel({ )} + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + {/* Dev Server Logs Panel */} {/* Backlog Plan Dialog */} diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index 7f052ef5..bdbe8a1c 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react'; +import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; export interface ProjectNavigationItem { @@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'theme', label: 'Theme', icon: Palette }, + { id: 'claude', label: 'Claude', icon: Bot }, { id: 'danger', label: 'Danger Zone', icon: AlertTriangle }, ]; diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index 19faf5e3..89cb87bc 100644 --- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; -export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger'; +export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger'; interface UseProjectSettingsViewOptions { initialView?: ProjectSettingsViewId; diff --git a/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx new file mode 100644 index 00000000..3ae17a83 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx @@ -0,0 +1,153 @@ +import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Bot, Cloud, Server, Globe } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Project } from '@/lib/electron'; + +interface ProjectClaudeSectionProps { + project: Project; +} + +export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) { + const { + claudeApiProfiles, + activeClaudeApiProfileId: globalActiveProfileId, + disabledProviders, + setProjectClaudeApiProfile, + } = useAppStore(); + const { claudeAuthStatus } = useSetupStore(); + + // Get project-level override from project + const projectActiveProfileId = project.activeClaudeApiProfileId; + + // Determine effective value for display + // undefined = use global, null = explicit direct, string = specific profile + const selectValue = + projectActiveProfileId === undefined + ? 'global' + : projectActiveProfileId === null + ? 'direct' + : projectActiveProfileId; + + // Check if Claude is available + const isClaudeDisabled = disabledProviders.includes('claude'); + const hasProfiles = claudeApiProfiles.length > 0; + const isClaudeAuthenticated = claudeAuthStatus?.authenticated; + + // Get global profile name for display + const globalProfile = globalActiveProfileId + ? claudeApiProfiles.find((p) => p.id === globalActiveProfileId) + : null; + const globalProfileName = globalProfile?.name || 'Direct Anthropic API'; + + const handleChange = (value: string) => { + // 'global' -> undefined (use global) + // 'direct' -> null (explicit direct) + // profile id -> string (specific profile) + const newValue = value === 'global' ? undefined : value === 'direct' ? null : value; + setProjectClaudeApiProfile(project.id, newValue); + }; + + // Don't render if Claude is disabled or not available + if (isClaudeDisabled || (!hasProfiles && !isClaudeAuthenticated)) { + return ( +
+ +

Claude not configured

+

+ Enable Claude and configure API profiles in global settings to use per-project profiles. +

+
+ ); + } + + // Get the display text for current selection + const getDisplayText = () => { + if (selectValue === 'global') { + return `Using global setting: ${globalProfileName}`; + } + if (selectValue === 'direct') { + return 'Using direct Anthropic API (API key or Claude Max plan)'; + } + const selectedProfile = claudeApiProfiles.find((p) => p.id === selectValue); + return `Using ${selectedProfile?.name || 'custom'} endpoint`; + }; + + return ( +
+
+
+
+ +
+

+ Claude API Profile +

+
+

+ Override the Claude API profile for this project only. +

+
+ +
+
+ + +

{getDisplayText()}

+
+ + {/* Info about what this affects */} +
+

This setting affects all Claude operations for this project including:

+
    +
  • Agent chat and feature implementation
  • +
  • Code analysis and suggestions
  • +
  • Commit message generation
  • +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index b570b1f4..f511bfc0 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; +import { ProjectClaudeSection } from './project-claude-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog'; import { ProjectSettingsNavigation } from './components/project-settings-navigation'; @@ -84,6 +85,8 @@ export function ProjectSettingsView() { return ; case 'worktrees': return ; + case 'claude': + return ; case 'danger': return ( )} diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index 840c8e63..c2ddebae 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -1,7 +1,7 @@ import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { Button } from '@/components/ui/button'; -import { Key, CheckCircle2, Trash2 } from 'lucide-react'; +import { Key, CheckCircle2, Trash2, Info } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { ApiKeyField } from './api-key-field'; import { buildProviderConfigs } from '@/config/api-providers'; @@ -101,9 +101,38 @@ export function ApiKeysSection() {

- {/* API Key Fields */} + {/* API Key Fields with contextual info */} {providerConfigs.map((provider) => ( - +
+ + {/* Anthropic-specific profile info */} + {provider.key === 'anthropic' && ( +
+
+ +
+

+ + Using Claude API Profiles? + {' '} + Create a profile in{' '} + AI Providers → Claude with{' '} + + credentials + {' '} + as the API key source to use this key. +

+

+ For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile + with{' '} + inline{' '} + key source and enter the provider's API key directly in the profile. +

+
+
+
+ )} +
))} {/* Security Notice */} diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx index 38b34c4c..4d69c07d 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx @@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings'; import { ClaudeUsageSection } from '../api-keys/claude-usage-section'; import { SkillsSection } from './claude-settings-tab/skills-section'; import { SubagentsSection } from './claude-settings-tab/subagents-section'; +import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section'; import { ProviderToggle } from './provider-toggle'; import { Info } from 'lucide-react'; @@ -45,6 +46,10 @@ export function ClaudeSettingsTab() { isChecking={isCheckingClaudeCli} onRefresh={handleRefreshClaudeCli} /> + + {/* API Profiles for Claude-compatible endpoints */} + + (null); + const [formData, setFormData] = useState(emptyFormData); + const [showApiKey, setShowApiKey] = useState(false); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [currentTemplate, setCurrentTemplate] = useState< + (typeof CLAUDE_API_PROFILE_TEMPLATES)[0] | null + >(null); + + const handleOpenAddDialog = (templateName?: string) => { + const template = templateName + ? CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.name === templateName) + : undefined; + + if (template) { + setFormData({ + name: template.name, + baseUrl: template.baseUrl, + apiKeySource: template.defaultApiKeySource ?? 'inline', + apiKey: '', + useAuthToken: template.useAuthToken, + timeoutMs: template.timeoutMs?.toString() ?? '', + modelMappings: { + haiku: template.modelMappings?.haiku ?? '', + sonnet: template.modelMappings?.sonnet ?? '', + opus: template.modelMappings?.opus ?? '', + }, + disableNonessentialTraffic: template.disableNonessentialTraffic ?? false, + }); + setCurrentTemplate(template); + } else { + setFormData(emptyFormData); + setCurrentTemplate(null); + } + + setEditingProfileId(null); + setShowApiKey(false); + setIsDialogOpen(true); + }; + + const handleOpenEditDialog = (profile: ClaudeApiProfile) => { + // Find matching template by base URL + const template = CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.baseUrl === profile.baseUrl); + + setFormData({ + name: profile.name, + baseUrl: profile.baseUrl, + apiKeySource: profile.apiKeySource ?? 'inline', + apiKey: profile.apiKey ?? '', + useAuthToken: profile.useAuthToken ?? false, + timeoutMs: profile.timeoutMs?.toString() ?? '', + modelMappings: { + haiku: profile.modelMappings?.haiku ?? '', + sonnet: profile.modelMappings?.sonnet ?? '', + opus: profile.modelMappings?.opus ?? '', + }, + disableNonessentialTraffic: profile.disableNonessentialTraffic ?? false, + }); + setEditingProfileId(profile.id); + setCurrentTemplate(template ?? null); + setShowApiKey(false); + setIsDialogOpen(true); + }; + + const handleSave = () => { + const profileData: ClaudeApiProfile = { + id: editingProfileId ?? generateProfileId(), + name: formData.name.trim(), + baseUrl: formData.baseUrl.trim(), + apiKeySource: formData.apiKeySource, + // Only include apiKey when source is 'inline' + apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined, + useAuthToken: formData.useAuthToken, + timeoutMs: (() => { + const parsed = Number(formData.timeoutMs); + return Number.isFinite(parsed) ? parsed : undefined; + })(), + modelMappings: + formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus + ? { + ...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }), + ...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }), + ...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }), + } + : undefined, + disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined, + }; + + if (editingProfileId) { + updateClaudeApiProfile(editingProfileId, profileData); + } else { + addClaudeApiProfile(profileData); + } + + setIsDialogOpen(false); + setFormData(emptyFormData); + setEditingProfileId(null); + }; + + const handleDelete = (id: string) => { + deleteClaudeApiProfile(id); + setDeleteConfirmId(null); + }; + + // Check for duplicate profile name (case-insensitive, excluding current profile when editing) + const isDuplicateName = claudeApiProfiles.some( + (p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProfileId + ); + + // API key is only required when source is 'inline' + const isFormValid = + formData.name.trim().length > 0 && + formData.baseUrl.trim().length > 0 && + (formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) && + !isDuplicateName; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

API Profiles

+

Manage Claude-compatible API endpoints

+
+
+ + + + + + handleOpenAddDialog()}> + + Custom Profile + + + {CLAUDE_API_PROFILE_TEMPLATES.map((template) => ( + handleOpenAddDialog(template.name)} + > + + {template.name} + + ))} + + +
+ + {/* Content */} +
+ {/* Active Profile Selector */} +
+ + +

+ {activeClaudeApiProfileId + ? 'Using custom API endpoint' + : 'Using direct Anthropic API (API key or Claude Max plan)'} +

+
+ + {/* Profile List */} + {claudeApiProfiles.length === 0 ? ( +
+ +

No API profiles configured

+

+ Add a profile to use alternative Claude-compatible endpoints +

+
+ ) : ( +
+ {claudeApiProfiles.map((profile) => ( + handleOpenEditDialog(profile)} + onDelete={() => setDeleteConfirmId(profile.id)} + onSetActive={() => setActiveClaudeApiProfile(profile.id)} + /> + ))} +
+ )} +
+ + {/* Add/Edit Dialog */} + + + + {editingProfileId ? 'Edit API Profile' : 'Add API Profile'} + + Configure a Claude-compatible API endpoint. API keys are stored locally. + + + +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., z.AI GLM" + className={isDuplicateName ? 'border-destructive' : ''} + /> + {isDuplicateName && ( +

A profile with this name already exists

+ )} +
+ + {/* Base URL */} +
+ + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ + {/* API Key Source */} +
+ + + {formData.apiKeySource === 'credentials' && ( +

+ Will use the Anthropic key from Settings → API Keys +

+ )} + {formData.apiKeySource === 'env' && ( +

+ Will use ANTHROPIC_API_KEY environment variable +

+ )} +
+ + {/* API Key (only shown for inline source) */} + {formData.apiKeySource === 'inline' && ( +
+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + + )} +
+ )} + + {/* Use Auth Token */} +
+
+ +

+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY +

+
+ setFormData({ ...formData, useAuthToken: checked })} + /> +
+ + {/* Timeout */} +
+ + setFormData({ ...formData, timeoutMs: e.target.value })} + placeholder="Optional, e.g., 3000000" + /> +
+ + {/* Model Mappings */} +
+ +

+ Map Claude model aliases to provider-specific model names +

+
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, haiku: e.target.value }, + }) + } + placeholder="e.g., GLM-4.5-Flash" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, sonnet: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, opus: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+
+ + {/* Disable Non-essential Traffic */} +
+
+ +

+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +

+
+ + setFormData({ ...formData, disableNonessentialTraffic: checked }) + } + /> +
+
+ + + + + +
+
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteConfirmId(null)}> + + + Delete Profile? + + This will permanently delete the API profile. If this profile is currently active, you + will be switched to direct Anthropic API. + + + + + + + + +
+ ); +} + +interface ProfileCardProps { + profile: ClaudeApiProfile; + isActive: boolean; + onEdit: () => void; + onDelete: () => void; + onSetActive: () => void; +} + +function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) { + return ( +
+
+
+
+

{profile.name}

+ {isActive && ( + + Active + + )} +
+

{profile.baseUrl}

+
+ Key: {maskApiKey(profile.apiKey)} + {profile.useAuthToken && Auth Token} + {profile.timeoutMs && Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s} +
+
+ + + + + + + {!isActive && ( + + + Set Active + + )} + + + Edit + + + + + Delete + + + +
+
+ ); +} diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 8175b16a..b62f6fa4 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -1,13 +1,24 @@ import { useEffect, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { createLogger } from '@automaker/utils/logger'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import type { AutoModeEvent } from '@/types/electron'; +import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types'; const logger = createLogger('AutoMode'); -const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath'; +const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey'; + +/** + * Generate a worktree key for session storage + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ +function getWorktreeSessionKey(projectPath: string, branchName: string | null): string { + return `${projectPath}::${branchName ?? '__main__'}`; +} function readAutoModeSession(): Record { try { @@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record): void { } } -function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void { +function setAutoModeSessionForWorktree( + projectPath: string, + branchName: string | null, + running: boolean +): void { + const worktreeKey = getWorktreeSessionKey(projectPath, branchName); const current = readAutoModeSession(); - const next = { ...current, [projectPath]: running }; + const next = { ...current, [worktreeKey]: running }; writeAutoModeSession(next); } @@ -45,33 +61,44 @@ function isPlanApprovalEvent( } /** - * Hook for managing auto mode (scoped per project) + * Hook for managing auto mode (scoped per worktree) + * @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null) */ -export function useAutoMode() { +export function useAutoMode(worktree?: WorktreeInfo) { const { - autoModeByProject, + autoModeByWorktree, setAutoModeRunning, addRunningTask, removeRunningTask, currentProject, addAutoModeActivity, - maxConcurrency, projects, setPendingPlanApproval, + getWorktreeKey, + getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree, } = useAppStore( useShallow((state) => ({ - autoModeByProject: state.autoModeByProject, + autoModeByWorktree: state.autoModeByWorktree, setAutoModeRunning: state.setAutoModeRunning, addRunningTask: state.addRunningTask, removeRunningTask: state.removeRunningTask, currentProject: state.currentProject, addAutoModeActivity: state.addAutoModeActivity, - maxConcurrency: state.maxConcurrency, projects: state.projects, setPendingPlanApproval: state.setPendingPlanApproval, + getWorktreeKey: state.getWorktreeKey, + getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree, })) ); + // Derive branchName from worktree: main worktree uses null, feature worktrees use their branch + const branchName = useMemo(() => { + if (!worktree) return null; + return worktree.isMain ? null : worktree.branch; + }, [worktree]); + // Helper to look up project ID from path const getProjectIdFromPath = useCallback( (path: string): string | undefined => { @@ -81,15 +108,30 @@ export function useAutoMode() { [projects] ); - // Get project-specific auto mode state + // Get worktree-specific auto mode state const projectId = currentProject?.id; - const projectAutoModeState = useMemo(() => { - if (!projectId) return { isRunning: false, runningTasks: [] }; - return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }; - }, [autoModeByProject, projectId]); + const worktreeAutoModeState = useMemo(() => { + if (!projectId) + return { + isRunning: false, + runningTasks: [], + branchName: null, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + }; + const key = getWorktreeKey(projectId, branchName); + return ( + autoModeByWorktree[key] || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + } + ); + }, [autoModeByWorktree, projectId, branchName, getWorktreeKey]); - const isAutoModeRunning = projectAutoModeState.isRunning; - const runningAutoTasks = projectAutoModeState.runningTasks; + const isAutoModeRunning = worktreeAutoModeState.isRunning; + const runningAutoTasks = worktreeAutoModeState.runningTasks; + const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; // Check if we can start a new task based on concurrency limit const canStartNewTask = runningAutoTasks.length < maxConcurrency; @@ -104,15 +146,17 @@ export function useAutoMode() { const api = getElectronAPI(); if (!api?.autoMode?.status) return; - const result = await api.autoMode.status(currentProject.path); + const result = await api.autoMode.status(currentProject.path, branchName); if (result.success && result.isAutoLoopRunning !== undefined) { const backendIsRunning = result.isAutoLoopRunning; + if (backendIsRunning !== isAutoModeRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` + `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` ); - setAutoModeRunning(currentProject.id, backendIsRunning); - setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning); + setAutoModeRunning(currentProject.id, branchName, backendIsRunning); + setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); } } } catch (error) { @@ -121,9 +165,9 @@ export function useAutoMode() { }; syncWithBackend(); - }, [currentProject, isAutoModeRunning, setAutoModeRunning]); + }, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]); - // Handle auto mode events - listen globally for all projects + // Handle auto mode events - listen globally for all projects/worktrees useEffect(() => { const api = getElectronAPI(); if (!api?.autoMode) return; @@ -131,8 +175,8 @@ export function useAutoMode() { const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { logger.info('Event:', event); - // Events include projectPath from backend - use it to look up project ID - // Fall back to current projectId if not provided in event + // Events include projectPath and branchName from backend + // Use them to look up project ID and determine the worktree let eventProjectId: string | undefined; if ('projectPath' in event && event.projectPath) { eventProjectId = getProjectIdFromPath(event.projectPath); @@ -144,6 +188,10 @@ export function useAutoMode() { eventProjectId = projectId; } + // Extract branchName from event, defaulting to null (main worktree) + const eventBranchName: string | null = + 'branchName' in event && event.branchName !== undefined ? event.branchName : null; + // Skip event if we couldn't determine the project if (!eventProjectId) { logger.warn('Could not determine project for event:', event); @@ -153,23 +201,34 @@ 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); + { + const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree'; + logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`); + if (eventProjectId) { + // Extract maxConcurrency from event if available, otherwise use current or default + const eventMaxConcurrency = + 'maxConcurrency' in event && typeof event.maxConcurrency === 'number' + ? event.maxConcurrency + : getMaxConcurrencyForWorktree(eventProjectId, eventBranchName); + setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency); + } } 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); + { + const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree'; + logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`); + if (eventProjectId) { + setAutoModeRunning(eventProjectId, eventBranchName, false); + } } break; case 'auto_mode_feature_start': if (event.featureId) { - addRunningTask(eventProjectId, event.featureId); + addRunningTask(eventProjectId, eventBranchName, event.featureId); addAutoModeActivity({ featureId: event.featureId, type: 'start', @@ -182,7 +241,7 @@ export function useAutoMode() { // Feature completed - remove from running tasks and UI will reload features on its own if (event.featureId) { logger.info('Feature completed:', event.featureId, 'passes:', event.passes); - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); addAutoModeActivity({ featureId: event.featureId, type: 'complete', @@ -202,7 +261,7 @@ export function useAutoMode() { logger.info('Feature cancelled/aborted:', event.error); // Remove from running tasks if (eventProjectId) { - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); } break; } @@ -229,7 +288,7 @@ export function useAutoMode() { // Remove the task from running since it failed if (eventProjectId) { - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); } } break; @@ -404,9 +463,11 @@ export function useAutoMode() { setPendingPlanApproval, setAutoModeRunning, currentProject?.path, + getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree, ]); - // Start auto mode - calls backend to start the auto loop + // Start auto mode - calls backend to start the auto loop for this worktree const start = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); @@ -419,36 +480,35 @@ export function useAutoMode() { throw new Error('Start auto mode API not available'); } - logger.info( - `[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}` - ); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true); - // Call backend to start the auto loop - const result = await api.autoMode.start(currentProject.path, maxConcurrency); + // Call backend to start the auto loop (backend uses stored concurrency) + const result = await api.autoMode.start(currentProject.path, branchName); if (!result.success) { // Revert UI state on failure - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, 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`); + logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`); } catch (error) { // Revert UI state on error - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); logger.error('Error starting auto mode:', error); throw error; } - }, [currentProject, setAutoModeRunning, maxConcurrency]); + }, [currentProject, branchName, setAutoModeRunning]); - // Stop auto mode - calls backend to stop the auto loop + // Stop auto mode - calls backend to stop the auto loop for this worktree const stop = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); @@ -461,34 +521,35 @@ export function useAutoMode() { throw new Error('Stop auto mode API not available'); } - logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); // Call backend to stop the auto loop - const result = await api.autoMode.stop(currentProject.path); + const result = await api.autoMode.stop(currentProject.path, branchName); if (!result.success) { // Revert UI state on failure - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, 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'); + logger.info(`Stopped ${worktreeDesc} - running tasks will continue`); } catch (error) { // Revert UI state on error - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true); logger.error('Error stopping auto mode:', error); throw error; } - }, [currentProject, setAutoModeRunning]); + }, [currentProject, branchName, setAutoModeRunning]); // Stop a specific feature const stopFeature = useCallback( @@ -507,7 +568,7 @@ export function useAutoMode() { const result = await api.autoMode.stopFeature(featureId); if (result.success) { - removeRunningTask(currentProject.id, featureId); + removeRunningTask(currentProject.id, branchName, featureId); logger.info('Feature stopped successfully:', featureId); addAutoModeActivity({ featureId, @@ -524,7 +585,7 @@ export function useAutoMode() { throw error; } }, - [currentProject, removeRunningTask, addAutoModeActivity] + [currentProject, branchName, removeRunningTask, addAutoModeActivity] ); return { @@ -532,6 +593,7 @@ export function useAutoMode() { runningTasks: runningAutoTasks, maxConcurrency, canStartNewTask, + branchName, start, stop, stopFeature, diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 3f179942..de8d70f2 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -25,6 +25,7 @@ export function useProjectSettingsLoader() { const setAutoDismissInitScriptIndicator = useAppStore( (state) => state.setAutoDismissInitScriptIndicator ); + const setCurrentProject = useAppStore((state) => state.setCurrentProject); const appliedProjectRef = useRef(null); @@ -90,6 +91,21 @@ export function useProjectSettingsLoader() { if (settings.autoDismissInitScriptIndicator !== undefined) { setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator); } + + // Apply activeClaudeApiProfileId if present + if (settings.activeClaudeApiProfileId !== undefined) { + const updatedProject = useAppStore.getState().currentProject; + if ( + updatedProject && + updatedProject.path === projectPath && + updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId + ) { + setCurrentProject({ + ...updatedProject, + activeClaudeApiProfileId: settings.activeClaudeApiProfileId, + }); + } + } }, [ currentProject?.path, settings, @@ -105,5 +121,6 @@ export function useProjectSettingsLoader() { setShowInitScriptIndicator, setDefaultDeleteBranch, setAutoDismissInitScriptIndicator, + setCurrentProject, ]); } diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 58b3ec2d..05b8d183 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, migrateCursorModelIds, @@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial | null { keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'], mcpServers: state.mcpServers as GlobalSettings['mcpServers'], promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'], + eventHooks: state.eventHooks as GlobalSettings['eventHooks'], projects: state.projects as GlobalSettings['projects'], trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'], currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null, @@ -206,6 +208,10 @@ export function parseLocalStorageSettings(): Partial | null { worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean), lastProjectDir: lastProjectDir || (state.lastProjectDir as string), recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]), + // Claude API Profiles + claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], + activeClaudeApiProfileId: + (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, }; } catch (error) { logger.error('Failed to parse localStorage settings:', error); @@ -326,6 +332,20 @@ export function mergeSettings( merged.currentProjectId = localSettings.currentProjectId; } + // Claude API Profiles - preserve from localStorage if server is empty + if ( + (!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) && + localSettings.claudeApiProfiles && + localSettings.claudeApiProfiles.length > 0 + ) { + merged.claudeApiProfiles = localSettings.claudeApiProfiles; + } + + // Active Claude API Profile ID - preserve from localStorage if server doesn't have one + if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) { + merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId; + } + return merged; } @@ -635,13 +655,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { setItem(THEME_STORAGE_KEY, storedTheme); } + // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset) + const restoredAutoModeByWorktree: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; + branchName: string | null; + maxConcurrency: number; + } + > = {}; + if ((settings as Record).autoModeByWorktree) { + const persistedSettings = (settings as Record).autoModeByWorktree as Record< + string, + { maxConcurrency?: number; branchName?: string | null } + >; + for (const [key, value] of Object.entries(persistedSettings)) { + restoredAutoModeByWorktree[key] = { + isRunning: false, // Always start with auto mode off + runningTasks: [], // No running tasks on startup + branchName: value.branchName ?? null, + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }; + } + } + useAppStore.setState({ theme: settings.theme as unknown as import('@/store/app-store').ThemeMode, fontFamilySans: settings.fontFamilySans ?? null, fontFamilyMono: settings.fontFamilyMono ?? null, sidebarOpen: settings.sidebarOpen ?? true, chatHistoryOpen: settings.chatHistoryOpen ?? false, - maxConcurrency: settings.maxConcurrency ?? 3, + maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + autoModeByWorktree: restoredAutoModeByWorktree, defaultSkipTests: settings.defaultSkipTests ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, @@ -671,6 +717,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { }, mcpServers: settings.mcpServers ?? [], promptCustomization: settings.promptCustomization ?? {}, + eventHooks: settings.eventHooks ?? [], + claudeApiProfiles: settings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null, projects, currentProject, trashedProjects: settings.trashedProjects ?? [], @@ -705,6 +754,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { function buildSettingsUpdateFromStore(): Record { const state = useAppStore.getState(); const setupState = useSetupStore.getState(); + + // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks) + const persistedAutoModeByWorktree: Record< + string, + { maxConcurrency: number; branchName: string | null } + > = {}; + for (const [key, value] of Object.entries(state.autoModeByWorktree)) { + persistedAutoModeByWorktree[key] = { + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName: value.branchName, + }; + } + return { setupComplete: setupState.setupComplete, isFirstRun: setupState.isFirstRun, @@ -713,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record { sidebarOpen: state.sidebarOpen, chatHistoryOpen: state.chatHistoryOpen, maxConcurrency: state.maxConcurrency, + autoModeByWorktree: persistedAutoModeByWorktree, defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, skipVerificationInAutoMode: state.skipVerificationInAutoMode, @@ -732,6 +795,9 @@ function buildSettingsUpdateFromStore(): Record { keyboardShortcuts: state.keyboardShortcuts, mcpServers: state.mcpServers, promptCustomization: state.promptCustomization, + eventHooks: state.eventHooks, + claudeApiProfiles: state.claudeApiProfiles, + activeClaudeApiProfileId: state.activeClaudeApiProfileId, projects: state.projects, trashedProjects: state.trashedProjects, currentProjectId: state.currentProject?.id ?? null, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index c978b6a7..8ede5600 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; import { DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, migrateCursorModelIds, @@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'sidebarOpen', 'chatHistoryOpen', 'maxConcurrency', + 'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted) 'defaultSkipTests', 'enableDependencyBlocking', 'skipVerificationInAutoMode', @@ -72,6 +74,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultTerminalId', 'promptCustomization', 'eventHooks', + 'claudeApiProfiles', + 'activeClaudeApiProfileId', 'projects', 'trashedProjects', 'currentProjectId', // ID of currently open project @@ -112,6 +116,19 @@ function getSettingsFieldValue( if (field === 'openTerminalMode') { return appState.terminalState.openTerminalMode; } + if (field === 'autoModeByWorktree') { + // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks) + const autoModeByWorktree = appState.autoModeByWorktree; + const persistedSettings: Record = + {}; + for (const [key, value] of Object.entries(autoModeByWorktree)) { + persistedSettings[key] = { + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName: value.branchName, + }; + } + return persistedSettings; + } return appState[field as keyof typeof appState]; } @@ -591,11 +608,37 @@ export async function refreshSettingsFromServer(): Promise { setItem(THEME_STORAGE_KEY, serverSettings.theme); } + // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset) + const restoredAutoModeByWorktree: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; + branchName: string | null; + maxConcurrency: number; + } + > = {}; + if (serverSettings.autoModeByWorktree) { + const persistedSettings = serverSettings.autoModeByWorktree as Record< + string, + { maxConcurrency?: number; branchName?: string | null } + >; + for (const [key, value] of Object.entries(persistedSettings)) { + restoredAutoModeByWorktree[key] = { + isRunning: false, // Always start with auto mode off + runningTasks: [], // No running tasks on startup + branchName: value.branchName ?? null, + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }; + } + } + useAppStore.setState({ theme: serverSettings.theme as unknown as ThemeMode, sidebarOpen: serverSettings.sidebarOpen, chatHistoryOpen: serverSettings.chatHistoryOpen, maxConcurrency: serverSettings.maxConcurrency, + autoModeByWorktree: restoredAutoModeByWorktree, defaultSkipTests: serverSettings.defaultSkipTests, enableDependencyBlocking: serverSettings.enableDependencyBlocking, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, @@ -628,6 +671,8 @@ export async function refreshSettingsFromServer(): Promise { defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, defaultTerminalId: serverSettings.defaultTerminalId ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, + claudeApiProfiles: serverSettings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null, projects: serverSettings.projects, trashedProjects: serverSettings.trashedProjects, projectHistory: serverSettings.projectHistory, @@ -637,6 +682,8 @@ export async function refreshSettingsFromServer(): Promise { worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, lastProjectDir: serverSettings.lastProjectDir ?? '', recentFolders: serverSettings.recentFolders ?? [], + // Event hooks + eventHooks: serverSettings.eventHooks ?? [], // Terminal settings (nested in terminalState) ...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && { terminalState: { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 9c834955..903b1bda 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -28,6 +28,7 @@ import type { UpdateIdeaInput, ConvertToFeatureOptions, } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; // Re-export issue validation types for use in components @@ -479,20 +480,26 @@ export interface FeaturesAPI { featureId: string ) => Promise<{ success: boolean; content?: string | null; error?: string }>; generateTitle: ( - description: string + description: string, + projectPath?: string ) => Promise<{ success: boolean; title?: string; error?: string }>; } export interface AutoModeAPI { start: ( projectPath: string, + branchName?: string | null, maxConcurrency?: number ) => Promise<{ success: boolean; error?: string }>; stop: ( - projectPath: string + projectPath: string, + branchName?: string | null ) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>; stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>; - status: (projectPath?: string) => Promise<{ + status: ( + projectPath?: string, + branchName?: string | null + ) => Promise<{ success: boolean; isRunning?: boolean; isAutoLoopRunning?: boolean; @@ -706,7 +713,8 @@ export interface ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ) => Promise<{ success: boolean; enhancedText?: string; @@ -2016,6 +2024,20 @@ function createMockWorktreeAPI(): WorktreeAPI { console.log('[Mock] Unsubscribing from init script events'); }; }, + + discardChanges: async (worktreePath: string) => { + console.log('[Mock] Discarding changes:', { worktreePath }); + return { + success: true, + result: { + discarded: true, + filesDiscarded: 0, + filesRemaining: 0, + branch: 'main', + message: 'Mock: Changes discarded successfully', + }, + }; + }, }; } @@ -2060,7 +2082,9 @@ function createMockAutoModeAPI(): AutoModeAPI { } mockAutoModeRunning = true; - console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`); + console.log( + `[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}` + ); const featureId = 'auto-mode-0'; mockRunningFeatures.add(featureId); @@ -3173,7 +3197,7 @@ function createMockFeaturesAPI(): FeaturesAPI { return { success: true, content: content || null }; }, - generateTitle: async (description: string) => { + generateTitle: async (description: string, _projectPath?: string) => { console.log('[Mock] Generating title for:', description.substring(0, 50)); // Mock title generation - just take first few words const words = description.split(/\s+/).slice(0, 6).join(' '); @@ -3349,6 +3373,13 @@ export interface Project { isFavorite?: boolean; // Pin project to top of dashboard icon?: string; // Lucide icon name for project identification customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/ + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } export interface TrashedProject extends Project { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ba2b8dd3..e6292bd7 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/features/delete', { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => this.post('/api/features/agent-output', { projectPath, featureId }), - generateTitle: (description: string) => - this.post('/api/features/generate-title', { description }), + generateTitle: (description: string, projectPath?: string) => + this.post('/api/features/generate-title', { description, projectPath }), bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) => this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), bulkDelete: (projectPath: string, featureIds: string[]) => @@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI { // Auto Mode API autoMode: AutoModeAPI = { - start: (projectPath: string, maxConcurrency?: number) => - this.post('/api/auto-mode/start', { projectPath, maxConcurrency }), - stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }), + start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) => + this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }), + stop: (projectPath: string, branchName?: string | null) => + this.post('/api/auto-mode/stop', { projectPath, branchName }), stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }), - status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }), + status: (projectPath?: string, branchName?: string | null) => + this.post('/api/auto-mode/status', { projectPath, branchName }), runFeature: ( projectPath: string, featureId: string, @@ -1743,13 +1745,15 @@ export class HttpApiClient implements ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ): Promise => this.post('/api/enhance-prompt', { originalText, enhancementMode, model, thinkingLevel, + projectPath, }), }; @@ -1847,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI { this.httpDelete('/api/worktree/init-script', { projectPath }), runInitScript: (projectPath: string, worktreePath: string, branch: string) => this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), + discardChanges: (worktreePath: string) => + this.post('/api/worktree/discard-changes', { worktreePath }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6030033d..f81d7bb6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) import type { Project, TrashedProject } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; import { setItem, getItem } from '@/lib/storage'; import { @@ -31,6 +32,7 @@ import type { ModelDefinition, ServerLogLevel, EventHook, + ClaudeApiProfile, } from '@automaker/types'; import { getAllCursorModelIds, @@ -38,6 +40,7 @@ import { getAllOpencodeModelIds, DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, } from '@automaker/types'; const logger = createLogger('AppStore'); @@ -626,16 +629,18 @@ export interface AppState { currentChatSession: ChatSession | null; chatHistoryOpen: boolean; - // Auto Mode (per-project state, keyed by project ID) - autoModeByProject: Record< + // Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}") + autoModeByWorktree: Record< string, { isRunning: boolean; runningTasks: string[]; // Feature IDs being worked on + branchName: string | null; // null = main worktree + maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3) } >; autoModeActivityLog: AutoModeActivity[]; - maxConcurrency: number; // Maximum number of concurrent agent tasks + maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency) // Kanban Card Display Settings boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view @@ -747,6 +752,10 @@ export interface AppState { // Event Hooks eventHooks: EventHook[]; // Event hooks for custom commands or webhooks + // Claude API Profiles + claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles + activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API) + // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; @@ -1030,6 +1039,9 @@ export interface AppActions { getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default) getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default) + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile) + // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; @@ -1057,18 +1069,36 @@ export interface AppActions { setChatHistoryOpen: (open: boolean) => void; toggleChatHistory: () => void; - // Auto Mode actions (per-project) - setAutoModeRunning: (projectId: string, running: boolean) => void; - addRunningTask: (projectId: string, taskId: string) => void; - removeRunningTask: (projectId: string, taskId: string) => void; - clearRunningTasks: (projectId: string) => void; - getAutoModeState: (projectId: string) => { + // Auto Mode actions (per-worktree) + setAutoModeRunning: ( + projectId: string, + branchName: string | null, + running: boolean, + maxConcurrency?: number + ) => void; + addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; + removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; + clearRunningTasks: (projectId: string, branchName: string | null) => void; + getAutoModeState: ( + projectId: string, + branchName: string | null + ) => { isRunning: boolean; runningTasks: string[]; + branchName: string | null; + maxConcurrency?: number; }; + /** Helper to generate worktree key from projectId and branchName */ + getWorktreeKey: (projectId: string, branchName: string | null) => string; addAutoModeActivity: (activity: Omit) => void; clearAutoModeActivity: () => void; - setMaxConcurrency: (max: number) => void; + setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility + getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number; + setMaxConcurrencyForWorktree: ( + projectId: string, + branchName: string | null, + maxConcurrency: number + ) => void; // Kanban Card Settings actions setBoardViewMode: (mode: BoardViewMode) => void; @@ -1180,6 +1210,13 @@ export interface AppActions { // Event Hook actions setEventHooks: (hooks: EventHook[]) => void; + // Claude API Profile actions + addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise; + updateClaudeApiProfile: (id: string, updates: Partial) => Promise; + deleteClaudeApiProfile: (id: string) => Promise; + setActiveClaudeApiProfile: (id: string | null) => Promise; + setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise; + // MCP Server actions addMCPServer: (server: Omit) => void; updateMCPServer: (id: string, updates: Partial) => void; @@ -1387,9 +1424,9 @@ const initialState: AppState = { chatSessions: [], currentChatSession: null, chatHistoryOpen: false, - autoModeByProject: {}, + autoModeByWorktree: {}, autoModeActivityLog: [], - maxConcurrency: 3, // Default to 3 concurrent agents + maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents boardViewMode: 'kanban', // Default to kanban view defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) @@ -1438,6 +1475,8 @@ const initialState: AppState = { subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults eventHooks: [], // No event hooks configured by default + claudeApiProfiles: [], // No Claude API profiles configured by default + activeClaudeApiProfileId: null, // Use direct Anthropic API by default projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, @@ -1936,6 +1975,47 @@ export const useAppStore = create()((set, get) => ({ return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS); }, + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId, profileId) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot set Claude API profile: project not found'); + return; + } + + // Update the project's activeClaudeApiProfileId property + // undefined means "use global", null means "explicit direct API", string means specific profile + const projects = get().projects.map((p) => + p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + activeClaudeApiProfileId: profileId, + }, + }); + } + + // Persist to server + // Note: undefined means "use global" but JSON doesn't serialize undefined, + // so we use a special marker string "__USE_GLOBAL__" to signal deletion + const httpClient = getHttpApiClient(); + const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId; + httpClient.settings + .updateProject(project.path, { + activeClaudeApiProfileId: serverValue, + }) + .catch((error) => { + console.error('Failed to persist activeClaudeApiProfileId:', error); + }); + }, + // Feature actions setFeatures: (features) => set({ features }), @@ -2073,74 +2153,125 @@ export const useAppStore = create()((set, get) => ({ toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }), - // Auto Mode actions (per-project) - setAutoModeRunning: (projectId, running) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + // Auto Mode actions (per-worktree) + getWorktreeKey: (projectId, branchName) => { + return `${projectId}::${branchName ?? '__main__'}`; + }, + + setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, + maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { ...projectState, isRunning: running }, + [worktreeKey]: { + ...worktreeState, + isRunning: running, + branchName, + maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }, }, }); }, - addRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + addRunningTask: (projectId, branchName, taskId) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; - if (!projectState.runningTasks.includes(taskId)) { + if (!worktreeState.runningTasks.includes(taskId)) { set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { - ...projectState, - runningTasks: [...projectState.runningTasks, taskId], + [worktreeKey]: { + ...worktreeState, + runningTasks: [...worktreeState.runningTasks, taskId], + branchName, }, }, }); } }, - removeRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + removeRunningTask: (projectId, branchName, taskId) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { - ...projectState, - runningTasks: projectState.runningTasks.filter((id) => id !== taskId), + [worktreeKey]: { + ...worktreeState, + runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId), + branchName, }, }, }); }, - clearRunningTasks: (projectId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + clearRunningTasks: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { ...projectState, runningTasks: [] }, + [worktreeKey]: { ...worktreeState, runningTasks: [], branchName }, }, }); }, - getAutoModeState: (projectId) => { - const projectState = get().autoModeByProject[projectId]; - return projectState || { isRunning: false, runningTasks: [] }; + getAutoModeState: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const worktreeState = get().autoModeByWorktree[worktreeKey]; + return ( + worktreeState || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + } + ); + }, + + getMaxConcurrencyForWorktree: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const worktreeState = get().autoModeByWorktree[worktreeKey]; + return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; + }, + + setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + }; + set({ + autoModeByWorktree: { + ...current, + [worktreeKey]: { ...worktreeState, maxConcurrency, branchName }, + }, + }); }, addAutoModeActivity: (activity) => { @@ -2459,6 +2590,82 @@ export const useAppStore = create()((set, get) => ({ // Event Hook actions setEventHooks: (hooks) => set({ eventHooks: hooks }), + // Claude API Profile actions + addClaudeApiProfile: async (profile) => { + set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] }); + // Sync immediately to persist profile + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + updateClaudeApiProfile: async (id, updates) => { + set({ + claudeApiProfiles: get().claudeApiProfiles.map((p) => + p.id === id ? { ...p, ...updates } : p + ), + }); + // Sync immediately to persist changes + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + deleteClaudeApiProfile: async (id) => { + const currentActiveId = get().activeClaudeApiProfileId; + const projects = get().projects; + + // Find projects that have per-project override referencing the deleted profile + const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id); + + // Update state: remove profile and clear references + set({ + claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id), + // Clear global active if the deleted profile was active + activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId, + // Clear per-project overrides that reference the deleted profile + projects: projects.map((p) => + p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p + ), + }); + + // Also update currentProject if it was using the deleted profile + const currentProject = get().currentProject; + if (currentProject?.activeClaudeApiProfileId === id) { + set({ + currentProject: { ...currentProject, activeClaudeApiProfileId: undefined }, + }); + } + + // Persist per-project changes to server (use __USE_GLOBAL__ marker) + const httpClient = getHttpApiClient(); + await Promise.all( + affectedProjects.map((project) => + httpClient.settings + .updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' }) + .catch((error) => { + console.error(`Failed to clear profile override for project ${project.name}:`, error); + }) + ) + ); + + // Sync global settings to persist deletion + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setActiveClaudeApiProfile: async (id) => { + set({ activeClaudeApiProfileId: id }); + // Sync immediately to persist active profile change + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setClaudeApiProfiles: async (profiles) => { + set({ claudeApiProfiles: profiles }); + // Sync immediately to persist profiles + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // MCP Server actions addMCPServer: (server) => { const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index a8e7c347..e01f3588 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -163,11 +163,30 @@ export interface SessionsAPI { } export type AutoModeEvent = + | { + type: 'auto_mode_started'; + message: string; + projectPath?: string; + branchName?: string | null; + } + | { + type: 'auto_mode_stopped'; + message: string; + projectPath?: string; + branchName?: string | null; + } + | { + type: 'auto_mode_idle'; + message: string; + projectPath?: string; + branchName?: string | null; + } | { type: 'auto_mode_feature_start'; featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; feature: unknown; } | { @@ -175,6 +194,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; content: string; } | { @@ -182,6 +202,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; tool: string; input: unknown; } @@ -190,6 +211,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; passes: boolean; message: string; } @@ -218,6 +240,7 @@ export type AutoModeEvent = featureId?: string; projectId?: string; projectPath?: string; + branchName?: string | null; } | { type: 'auto_mode_phase'; @@ -389,18 +412,48 @@ export interface SpecRegenerationAPI { } export interface AutoModeAPI { + start: ( + projectPath: string, + branchName?: string | null, + maxConcurrency?: number + ) => Promise<{ + success: boolean; + message?: string; + alreadyRunning?: boolean; + branchName?: string | null; + error?: string; + }>; + + stop: ( + projectPath: string, + branchName?: string | null + ) => Promise<{ + success: boolean; + message?: string; + wasRunning?: boolean; + runningFeaturesCount?: number; + branchName?: string | null; + error?: string; + }>; + stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string; }>; - status: (projectPath?: string) => Promise<{ + status: ( + projectPath?: string, + branchName?: string | null + ) => Promise<{ success: boolean; isRunning?: boolean; + isAutoLoopRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; runningProjects?: string[]; runningCount?: number; + maxConcurrency?: number; + branchName?: string | null; error?: string; }>; @@ -1165,6 +1218,19 @@ export interface WorktreeAPI { payload: unknown; }) => void ) => () => void; + + // Discard changes for a worktree + discardChanges: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + discarded: boolean; + filesDiscarded: number; + filesRemaining: number; + branch: string; + message: string; + }; + error?: string; + }>; } export interface GitAPI { diff --git a/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md new file mode 100644 index 00000000..4bb8e936 --- /dev/null +++ b/docs/UNIFIED_API_KEY_PROFILES.md @@ -0,0 +1,448 @@ +# Unified Claude API Key and Profile System + +This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved. + +## Problem Statement + +Previously, Automaker had two separate systems for configuring Claude API access: + +1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active +2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys + +This created several issues: + +- Users configured Anthropic key in one place, but alternative endpoints in another +- No way to create a "Direct Anthropic" profile that reused the stored credentials +- Environment variable detection didn't integrate with the profile system +- Duplicated API key entry when users wanted the same key for multiple configurations + +## Solution Overview + +The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from: + +| Source | Description | +| ------------- | ----------------------------------------------------------------- | +| `inline` | API key stored directly in the profile (legacy behavior, default) | +| `env` | Uses `ANTHROPIC_API_KEY` environment variable | +| `credentials` | Uses the Anthropic key from Settings → API Keys | + +This allows: + +- A single API key to be shared across multiple profile configurations +- "Direct Anthropic" profile that references saved credentials +- Environment variable support for CI/CD and containerized deployments +- Backwards compatibility with existing inline key profiles + +## Implementation Details + +### Type Changes + +#### New Type: `ApiKeySource` + +```typescript +// libs/types/src/settings.ts +export type ApiKeySource = 'inline' | 'env' | 'credentials'; +``` + +#### Updated Interface: `ClaudeApiProfile` + +```typescript +export interface ClaudeApiProfile { + id: string; + name: string; + baseUrl: string; + + // NEW: API key sourcing strategy (default: 'inline' for backwards compat) + apiKeySource?: ApiKeySource; + + // Now optional - only required when apiKeySource = 'inline' + apiKey?: string; + + // Existing fields unchanged... + useAuthToken?: boolean; + timeoutMs?: number; + modelMappings?: { haiku?: string; sonnet?: string; opus?: string }; + disableNonessentialTraffic?: boolean; +} +``` + +#### Updated Interface: `ClaudeApiProfileTemplate` + +```typescript +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template + useAuthToken: boolean; + // ... other fields +} +``` + +### Provider Templates + +The following provider templates are available: + +#### Direct Anthropic + +```typescript +{ + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', +} +``` + +#### OpenRouter + +Access Claude and 300+ other models through OpenRouter's unified API. + +```typescript +{ + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', +} +``` + +**Notes:** + +- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key +- No model mappings by default - OpenRouter auto-maps Anthropic models +- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`) + +#### z.AI GLM + +```typescript +{ + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', +} +``` + +#### MiniMax + +MiniMax M2.1 coding model with extended context support. + +```typescript +{ + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', +} +``` + +#### MiniMax (China) + +Same as MiniMax but using the China-region endpoint. + +```typescript +{ + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', +} +``` + +### Server-Side Changes + +#### 1. Environment Building (`claude-provider.ts`) + +The `buildEnv()` function now resolves API keys based on the `apiKeySource`: + +```typescript +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials // NEW parameter +): Record { + if (profile) { + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // ... rest of profile-based env building + } + // ... no-profile fallback +} +``` + +#### 2. Settings Helper (`settings-helpers.ts`) + +The `getActiveClaudeApiProfile()` function now returns both profile and credentials: + +```typescript +export interface ActiveClaudeApiProfileResult { + profile: ClaudeApiProfile | undefined; + credentials: Credentials | undefined; +} + +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + // Returns both profile and credentials for API key resolution +} +``` + +#### 3. Auto-Migration (`settings-service.ts`) + +A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users: + +```typescript +// Migration v4 -> v5: Auto-create "Direct Anthropic" profile +if (storedVersion < 5) { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles?.length; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + // Create "Direct Anthropic" profile with apiKeySource: 'credentials' + // and set it as active + } +} +``` + +#### 4. Updated Call Sites + +All files that call `getActiveClaudeApiProfile()` were updated to: + +1. Destructure both `profile` and `credentials` from the result +2. Pass `credentials` to the provider via `ExecuteOptions` + +**Files updated:** + +- `apps/server/src/services/agent-service.ts` +- `apps/server/src/services/auto-mode-service.ts` (2 locations) +- `apps/server/src/services/ideation-service.ts` (2 locations) +- `apps/server/src/providers/simple-query-service.ts` +- `apps/server/src/routes/enhance-prompt/routes/enhance.ts` +- `apps/server/src/routes/context/routes/describe-file.ts` +- `apps/server/src/routes/context/routes/describe-image.ts` +- `apps/server/src/routes/github/routes/validate-issue.ts` +- `apps/server/src/routes/worktree/routes/generate-commit-message.ts` +- `apps/server/src/routes/features/routes/generate-title.ts` +- `apps/server/src/routes/backlog-plan/generate-plan.ts` +- `apps/server/src/routes/app-spec/sync-spec.ts` +- `apps/server/src/routes/app-spec/generate-features-from-spec.ts` +- `apps/server/src/routes/app-spec/generate-spec.ts` +- `apps/server/src/routes/suggestions/generate-suggestions.ts` + +### UI Changes + +#### 1. Profile Form (`api-profiles-section.tsx`) + +Added an API Key Source selector dropdown: + +```tsx + +``` + +The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. + +#### 2. API Keys Section (`api-keys-section.tsx`) + +Added an informational note: + +> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it. + +## User Flows + +### New User Flow + +1. Go to Settings → API Keys +2. Enter Anthropic API key and save +3. Go to Settings → Providers → Claude +4. Create new profile from "Direct Anthropic" template +5. API Key Source defaults to "credentials" - no need to re-enter key +6. Save profile and set as active + +### Existing User Migration + +When an existing user with an Anthropic API key (but no profiles) loads settings: + +1. System detects v4→v5 migration needed +2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'` +3. Sets new profile as active +4. User's existing workflow continues to work seamlessly + +### Environment Variable Flow + +For CI/CD or containerized deployments: + +1. Set `ANTHROPIC_API_KEY` in environment +2. Create profile with `apiKeySource: 'env'` +3. Profile will use the environment variable at runtime + +## Backwards Compatibility + +- Profiles without `apiKeySource` field default to `'inline'` +- Existing profiles with inline `apiKey` continue to work unchanged +- No changes to the credentials file format +- Settings version bumped from 4 to 5 (migration is additive) + +## Files Changed + +| File | Changes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | +| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | +| `libs/types/src/index.ts` | Exported `ApiKeySource` type | +| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | +| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | +| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration | +| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | +| `apps/server/src/services/*.ts` | Updated to pass credentials | +| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | +| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | +| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | + +## Testing + +To verify the implementation: + +1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works +2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile +3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works +4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working +5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline` + +```bash +# Build and run +npm run build:packages +npm run dev:web + +# Run server tests +npm run test:server +``` + +## Per-Project Profile Override + +Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations. + +### Configuration + +In **Project Settings → Claude**, users can select: + +| Option | Behavior | +| ------------------------ | ------------------------------------------------------------------ | +| **Use Global Setting** | Inherits the active profile from global settings (default) | +| **Direct Anthropic API** | Explicitly uses direct Anthropic API, bypassing any global profile | +| **\** | Uses that specific profile for this project only | + +### Storage + +The per-project setting is stored in `.automaker/settings.json`: + +```json +{ + "activeClaudeApiProfileId": "profile-id-here" +} +``` + +- `undefined` (or key absent): Use global setting +- `null`: Explicitly use Direct Anthropic API +- `""`: Use specific profile by ID + +### Implementation + +The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter: + +```typescript +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string // Optional: check project settings first +): Promise; +``` + +When `projectPath` is provided: + +1. Project settings are checked first for `activeClaudeApiProfileId` +2. If project has a value (including `null`), that takes precedence +3. If project has no override (`undefined`), falls back to global setting + +### Scope + +**Important:** Per-project profiles only affect Claude model calls. When other providers are used (Codex, OpenCode, Cursor), the Claude API profile setting has no effect—those providers use their own configuration. + +Affected operations when using Claude models: + +- Agent chat and feature implementation +- Code analysis and suggestions +- Commit message generation +- Spec generation and sync +- Issue validation +- Backlog planning + +### Use Cases + +1. **Experimentation**: Test z.AI GLM or MiniMax on a side project while keeping production projects on Direct Anthropic +2. **Cost optimization**: Use cheaper endpoints for hobby projects, premium for work projects +3. **Regional compliance**: Use China endpoints for projects with data residency requirements + +## Future Enhancements + +Potential future improvements: + +1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources +2. **Validation**: Warn if selected source has no key configured +3. **Per-provider credentials**: Support different credential keys for different providers +4. **Key rotation**: Support for rotating keys without updating profiles diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 21985230..1ea410cc 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -161,6 +161,10 @@ export type { EventHookHttpAction, EventHookAction, EventHook, + // Claude API profile types + ApiKeySource, + ClaudeApiProfile, + ClaudeApiProfileTemplate, } from './settings.js'; export { DEFAULT_KEYBOARD_SHORTCUTS, @@ -168,6 +172,7 @@ export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, + DEFAULT_MAX_CONCURRENCY, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, @@ -175,6 +180,8 @@ export { getThinkingTokenBudget, // Event hook constants EVENT_HOOK_TRIGGER_LABELS, + // Claude API profile constants + CLAUDE_API_PROFILE_TEMPLATES, } from './settings.js'; // Model display constants diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index e934e999..6fddb460 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,7 +2,7 @@ * Shared types for AI model providers */ -import type { ThinkingLevel } from './settings.js'; +import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; /** @@ -209,6 +209,17 @@ export interface ExecuteOptions { type: 'json_schema'; schema: Record; }; + /** + * Active Claude API profile for alternative endpoint configuration. + * When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API. + * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth). + */ + claudeApiProfile?: ClaudeApiProfile; + /** + * Credentials for resolving 'credentials' apiKeySource in Claude API profiles. + * When a profile has apiKeySource='credentials', the Anthropic key from this object is used. + */ + credentials?: Credentials; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 64c3df41..0f96cbd6 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -101,6 +101,137 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number /** ModelProvider - AI model provider for credentials and API key management */ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +// ============================================================================ +// Claude API Profiles - Configuration for Claude-compatible API endpoints +// ============================================================================ + +/** + * ApiKeySource - Strategy for sourcing API keys + * + * - 'inline': API key stored directly in the profile (legacy/default behavior) + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json) + */ +export type ApiKeySource = 'inline' | 'env' | 'credentials'; + +/** + * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint + * + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ +export interface ClaudeApiProfile { + /** Unique identifier (uuid) */ + id: string; + /** Display name (e.g., "z.AI GLM", "AWS Bedrock") */ + name: string; + /** ANTHROPIC_BASE_URL - custom API endpoint */ + baseUrl: string; + /** + * API key sourcing strategy (default: 'inline' for backwards compatibility) + * - 'inline': Use apiKey field value + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from credentials.json + */ + apiKeySource?: ApiKeySource; + /** API key value (only required when apiKeySource = 'inline' or undefined) */ + apiKey?: string; + /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */ + useAuthToken?: boolean; + /** API_TIMEOUT_MS override in milliseconds */ + timeoutMs?: number; + /** Optional model name mappings */ + modelMappings?: { + /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */ + haiku?: string; + /** Maps to ANTHROPIC_DEFAULT_SONNET_MODEL */ + sonnet?: string; + /** Maps to ANTHROPIC_DEFAULT_OPUS_MODEL */ + opus?: string; + }; + /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */ + disableNonessentialTraffic?: boolean; +} + +/** Known provider templates for quick setup */ +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + /** Default API key source for this template (user chooses when creating) */ + defaultApiKeySource?: ApiKeySource; + useAuthToken: boolean; + timeoutMs?: number; + modelMappings?: ClaudeApiProfile['modelMappings']; + disableNonessentialTraffic?: boolean; + description: string; + apiKeyUrl?: string; +} + +/** Predefined templates for known Claude-compatible providers */ +export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ + { + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + }, + { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', + }, + { + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', + }, + { + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + }, + { + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + }, + // Future: Add AWS Bedrock, Google Vertex, etc. +]; + // ============================================================================ // Event Hooks - Custom actions triggered by system events // ============================================================================ @@ -658,6 +789,19 @@ export interface GlobalSettings { * @see EventHook for configuration details */ eventHooks?: EventHook[]; + + // Claude API Profiles Configuration + /** + * Claude-compatible API endpoint profiles + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ + claudeApiProfiles?: ClaudeApiProfile[]; + + /** + * Active profile ID (null/undefined = use direct Anthropic API) + * When set, the corresponding profile's settings will be used for Claude API calls + */ + activeClaudeApiProfileId?: string | null; } /** @@ -794,6 +938,15 @@ export interface ProjectSettings { automodeEnabled?: boolean; /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ maxConcurrentAgents?: number; + + // Claude API Profile Override (per-project) + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } /** @@ -827,12 +980,15 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 4; +export const SETTINGS_VERSION = 5; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ export const PROJECT_SETTINGS_VERSION = 1; +/** Default maximum concurrent agents for auto mode */ +export const DEFAULT_MAX_CONCURRENCY = 1; + /** Default keyboard shortcut bindings */ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { board: 'K', @@ -866,7 +1022,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { theme: 'dark', sidebarOpen: true, chatHistoryOpen: false, - maxConcurrency: 3, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, @@ -913,6 +1069,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { skillsSources: ['user', 'project'], enableSubagents: true, subagentsSources: ['user', 'project'], + claudeApiProfiles: [], + activeClaudeApiProfileId: null, }; /** Default credentials (empty strings - user must provide API keys) */ diff --git a/package-lock.json b/package-lock.json index 64192c40..c86ba4aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6218,6 +6218,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6227,7 +6228,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8438,6 +8439,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11331,7 +11333,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11353,7 +11354,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11375,7 +11375,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11397,7 +11396,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11419,7 +11417,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11441,7 +11438,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11463,7 +11459,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11485,7 +11480,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11507,7 +11501,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11529,7 +11522,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11551,7 +11543,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/package.json b/package.json index 96c9bf1e..f7388410 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "./start-automaker.sh", - "start": "./start-automaker.sh --production", + "dev": "node start-automaker.mjs", + "start": "node start-automaker.mjs --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start-automaker.mjs b/start-automaker.mjs new file mode 100644 index 00000000..97362312 --- /dev/null +++ b/start-automaker.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * Cross-platform launcher for Automaker + * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) + */ + +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { platform } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isWindows = platform() === 'win32'; +const args = process.argv.slice(2); + +/** + * Detect the bash variant by checking $OSTYPE + * This is more reliable than path-based detection since bash.exe in PATH + * could be Git Bash, WSL, or something else + * @param {string} bashPath - Path to bash executable + * @returns {'WSL' | 'MSYS' | 'CYGWIN' | 'UNKNOWN'} The detected bash variant + */ +function detectBashVariant(bashPath) { + try { + const result = spawnSync(bashPath, ['-c', 'echo $OSTYPE'], { + stdio: 'pipe', + timeout: 2000, + }); + if (result.status === 0) { + const ostype = result.stdout.toString().trim(); + // WSL reports 'linux-gnu' or similar Linux identifier + if (ostype === 'linux-gnu' || ostype.startsWith('linux')) return 'WSL'; + // MSYS2/Git Bash reports 'msys' or 'mingw*' + if (ostype.startsWith('msys') || ostype.startsWith('mingw')) return 'MSYS'; + // Cygwin reports 'cygwin' + if (ostype.startsWith('cygwin')) return 'CYGWIN'; + } + } catch { + // Fall through to path-based detection + } + // Fallback to path-based detection if $OSTYPE check fails + const lower = bashPath.toLowerCase(); + if (lower.includes('cygwin')) return 'CYGWIN'; + if (lower.includes('system32')) return 'WSL'; + // Default to MSYS (Git Bash) as it's the most common + return 'MSYS'; +} + +/** + * Convert Windows path to Unix-style for the detected bash variant + * @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file) + * @param {string} bashCmd - Path to bash executable (used to detect variant) + * @returns {string} Unix-style path appropriate for the bash variant + */ +function convertPathForBash(windowsPath, bashCmd) { + // Input validation + if (!windowsPath || typeof windowsPath !== 'string') { + throw new Error('convertPathForBash: invalid windowsPath'); + } + if (!bashCmd || typeof bashCmd !== 'string') { + throw new Error('convertPathForBash: invalid bashCmd'); + } + + let unixPath = windowsPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + const drive = unixPath[0].toLowerCase(); + const pathPart = unixPath.slice(2); + + // Detect bash variant via $OSTYPE (more reliable than path-based) + const variant = detectBashVariant(bashCmd); + switch (variant) { + case 'CYGWIN': + // Cygwin expects /cygdrive/c/path format + return `/cygdrive/${drive}${pathPart}`; + case 'WSL': + // WSL expects /mnt/c/path format + return `/mnt/${drive}${pathPart}`; + case 'MSYS': + default: + // MSYS2/Git Bash expects /c/path format + return `/${drive}${pathPart}`; + } + } + return unixPath; +} + +/** + * Find bash executable on Windows + */ +function findBashOnWindows() { + const possiblePaths = [ + // Git Bash (most common) + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // MSYS2 + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\msys32\\usr\\bin\\bash.exe', + // Cygwin + 'C:\\cygwin64\\bin\\bash.exe', + 'C:\\cygwin\\bin\\bash.exe', + // WSL bash (available in PATH on Windows 10+) + 'bash.exe', + ]; + + for (const bashPath of possiblePaths) { + if (bashPath === 'bash.exe') { + // Check if bash is in PATH + try { + const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' }); + if (result?.status === 0) { + return 'bash.exe'; + } + } catch (err) { + // where command failed, continue checking other paths + } + } else if (existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Run the bash script + */ +function runBashScript() { + const scriptPath = join(__dirname, 'start-automaker.sh'); + + if (!existsSync(scriptPath)) { + console.error('Error: start-automaker.sh not found'); + process.exit(1); + } + + let bashCmd; + let bashArgs; + + if (isWindows) { + bashCmd = findBashOnWindows(); + + if (!bashCmd) { + console.error('Error: Could not find bash on Windows.'); + console.error('Please install Git for Windows from https://git-scm.com/download/win'); + console.error(''); + console.error('Alternatively, you can run these commands directly:'); + console.error(' npm run dev:web - Web browser mode'); + console.error(' npm run dev:electron - Desktop app mode'); + process.exit(1); + } + + // Convert Windows path to appropriate Unix-style for the detected bash variant + const unixPath = convertPathForBash(scriptPath, bashCmd); + bashArgs = [unixPath, ...args]; + } else { + bashCmd = '/bin/bash'; + bashArgs = [scriptPath, ...args]; + } + + const child = spawn(bashCmd, bashArgs, { + stdio: 'inherit', + env: { + ...process.env, + // Ensure proper terminal handling + TERM: process.env.TERM || 'xterm-256color', + }, + // shell: false ensures signals are forwarded directly to the child process + shell: false, + }); + + child.on('error', (err) => { + if (err.code === 'ENOENT') { + console.error(`Error: Could not find bash at "${bashCmd}"`); + console.error('Please ensure Git Bash or another bash shell is installed.'); + } else { + console.error('Error launching Automaker:', err.message); + } + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Process was killed by a signal - exit with 1 to indicate abnormal termination + // (Unix convention is 128 + signal number, but we use 1 for simplicity) + process.exit(1); + } + process.exit(code ?? 0); + }); + + // Forward signals to child process (guard against race conditions) + process.on('SIGINT', () => { + if (!child.killed) child.kill('SIGINT'); + }); + process.on('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); + }); +} + +runBashScript(); diff --git a/start-automaker.sh b/start-automaker.sh index ef7b1172..5d9a30a4 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -34,9 +34,41 @@ fi # Port configuration DEFAULT_WEB_PORT=3007 DEFAULT_SERVER_PORT=3008 +PORT_SEARCH_MAX_ATTEMPTS=100 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Port validation function +# Returns 0 if valid, 1 if invalid (with error message printed) +validate_port() { + local port="$1" + local port_name="${2:-port}" + + # Check if port is a number + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "${C_RED}Error:${RESET} $port_name must be a number, got '$port'" + return 1 + fi + + # Check if port is in valid range (1-65535) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "${C_RED}Error:${RESET} $port_name must be between 1-65535, got '$port'" + return 1 + fi + + # Check if port is in privileged range (warning only) + if [ "$port" -lt 1024 ]; then + echo "${C_YELLOW}Warning:${RESET} $port_name $port is in privileged range (requires root/admin)" + fi + + return 0 +} + +# Hostname configuration +# Use VITE_HOSTNAME if explicitly set, otherwise default to localhost +# Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname +APP_HOST="${VITE_HOSTNAME:-localhost}" + # Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version) if command -v node &> /dev/null; then VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")" @@ -422,6 +454,25 @@ is_port_in_use() { [ -n "$pids" ] && [ "$pids" != " " ] } +# Find the next available port starting from a given port +# Returns the port on stdout if found, nothing if all ports in range are busy +# Exit code: 0 if found, 1 if no available port in range +find_next_available_port() { + local start_port=$1 + local port=$start_port + + for ((i=0; i/dev/null || true - + # Auto-discover available ports (no user interaction required) local web_in_use=false local server_in_use=false @@ -475,59 +524,46 @@ check_ports() { if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then echo "" + local max_port if [ "$web_in_use" = true ]; then local pids - pids=$(get_pids_on_port "$DEFAULT_WEB_PORT") - echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_WEB_PORT is in use by process(es): $pids" + # Get PIDs and convert newlines to spaces for display + pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs) + echo "${C_YELLOW}Port $DEFAULT_WEB_PORT in use (PID: $pids), finding alternative...${RESET}" + max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then + echo "${C_RED}Error: No free web port in range ${DEFAULT_WEB_PORT}-${max_port}${RESET}" + exit 1 + fi fi if [ "$server_in_use" = true ]; then local pids - pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT") - echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_SERVER_PORT is in use by process(es): $pids" + # Get PIDs and convert newlines to spaces for display + pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs) + echo "${C_YELLOW}Port $DEFAULT_SERVER_PORT in use (PID: $pids), finding alternative...${RESET}" + max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then + echo "${C_RED}Error: No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}${RESET}" + exit 1 + fi + fi + + # Ensure web and server ports don't conflict with each other + if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then + local conflict_start=$((SERVER_PORT + 1)) + max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then + echo "${C_RED}Error: No free server port in range ${conflict_start}-${max_port}${RESET}" + exit 1 + fi fi - echo "" - while true; do - read -r -p "What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: " choice - case "$choice" in - [kK]|[kK][iI][lL][lL]) - if [ "$web_in_use" = true ]; then - kill_port "$DEFAULT_WEB_PORT" - else - echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available" - fi - if [ "$server_in_use" = true ]; then - kill_port "$DEFAULT_SERVER_PORT" - else - echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available" - fi - break - ;; - [uU]|[uU][sS][eE]) - read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} - read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} - echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" - break - ;; - [cC]|[cC][aA][nN][cC][eE][lL]) - echo "${C_MUTE}Cancelled.${RESET}" - exit 0 - ;; - *) - echo "${C_RED}Invalid choice. Please enter k, u, or c.${RESET}" - ;; - esac - done echo "" + echo "${C_GREEN}✓ Auto-selected available ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" else echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available" echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available" fi - - hide_cursor - stty -echo -icanon 2>/dev/null || true } validate_terminal_size() { @@ -747,37 +783,70 @@ resolve_port_conflicts() { if is_port_in_use "$DEFAULT_WEB_PORT"; then web_in_use=true - web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT") + # Get PIDs and convert newlines to spaces for display + web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs) fi if is_port_in_use "$DEFAULT_SERVER_PORT"; then server_in_use=true - server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT") + # Get PIDs and convert newlines to spaces for display + server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs) fi if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then echo "" if [ "$web_in_use" = true ]; then - center_print "⚠ Port $DEFAULT_WEB_PORT is in use by process(es): $web_pids" "$C_YELLOW" + center_print "Port $DEFAULT_WEB_PORT in use (PID: $web_pids)" "$C_YELLOW" fi if [ "$server_in_use" = true ]; then - center_print "⚠ Port $DEFAULT_SERVER_PORT is in use by process(es): $server_pids" "$C_YELLOW" + center_print "Port $DEFAULT_SERVER_PORT in use (PID: $server_pids)" "$C_YELLOW" fi echo "" # Show options center_print "What would you like to do?" "$C_WHITE" echo "" - center_print "[K] Kill processes and continue" "$C_GREEN" - center_print "[U] Use different ports" "$C_MUTE" - center_print "[C] Cancel" "$C_RED" + center_print "[Enter] Auto-select available ports (Recommended)" "$C_GREEN" + center_print "[K] Kill processes and use default ports" "$C_MUTE" + center_print "[C] Choose custom ports" "$C_MUTE" + center_print "[X] Cancel" "$C_RED" echo "" while true; do local choice_pad=$(( (TERM_COLS - 20) / 2 )) printf "%${choice_pad}s" "" - read -r -p "Choice: " choice + read -r -p "Choice [Enter]: " choice case "$choice" in + ""|[aA]|[aA][uU][tT][oO]) + # Auto-select: find next available ports + echo "" + local max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if [ "$web_in_use" = true ]; then + if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then + center_print "No free web port in range ${DEFAULT_WEB_PORT}-${max_port}" "$C_RED" + exit 1 + fi + fi + max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if [ "$server_in_use" = true ]; then + if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then + center_print "No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}" "$C_RED" + exit 1 + fi + fi + # Ensure web and server ports don't conflict with each other + if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then + local conflict_start=$((SERVER_PORT + 1)) + max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then + center_print "No free server port in range ${conflict_start}-${max_port}" "$C_RED" + exit 1 + fi + fi + center_print "✓ Auto-selected available ports:" "$C_GREEN" + center_print " Web: $WEB_PORT | Server: $SERVER_PORT" "$C_PRI" + break + ;; [kK]|[kK][iI][lL][lL]) echo "" if [ "$web_in_use" = true ]; then @@ -792,26 +861,39 @@ resolve_port_conflicts() { fi break ;; - [uU]|[uU][sS][eE]) + [cC]|[cC][hH][oO][oO][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) + # Collect both ports first printf "%${input_pad}s" "" read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} printf "%${input_pad}s" "" read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;; - [cC]|[cC][aA][nN][cC][eE][lL]) + [xX]|[xX][cC][aA][nN][cC][eE][lL]) echo "" center_print "Cancelled." "$C_MUTE" echo "" exit 0 ;; *) - center_print "Invalid choice. Please enter K, U, or C." "$C_RED" + center_print "Invalid choice. Press Enter for auto-select, or K/C/X." "$C_RED" ;; esac done @@ -850,7 +932,7 @@ launch_sequence() { case "$MODE" in web) - local url="http://localhost:$WEB_PORT" + local url="http://${APP_HOST}:$WEB_PORT" local upad=$(( (TERM_COLS - ${#url} - 10) / 2 )) echo "" printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url" @@ -1073,10 +1155,15 @@ fi case $MODE in web) export TEST_PORT="$WEB_PORT" - export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT" + export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT" export DATA_DIR="$SCRIPT_DIR/data" - export CORS_ORIGIN="http://localhost:$WEB_PORT,http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + # Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different + CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then + CORS_ORIGINS="${CORS_ORIGINS},http://${APP_HOST}:$WEB_PORT" + fi + export CORS_ORIGIN="$CORS_ORIGINS" export VITE_APP_MODE="1" if [ "$PRODUCTION_MODE" = true ]; then @@ -1092,7 +1179,7 @@ case $MODE in max_retries=30 server_ready=false for ((i=0; i /dev/null 2>&1; then + if curl -s "http://localhost:$SERVER_PORT/api/health" > /dev/null 2>&1; then server_ready=true break fi @@ -1148,7 +1235,7 @@ case $MODE in center_print "✓ Server is ready!" "$C_GREEN" echo "" - center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN" + center_print "The application will be available at: http://${APP_HOST}:$WEB_PORT" "$C_GREEN" echo "" # Start web app with Vite dev server (HMR enabled) diff --git a/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx new file mode 100644 index 00000000..69392afa --- /dev/null +++ b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -0,0 +1,1582 @@ +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { useIsMobile } from '@/hooks/use-media-query'; +import type { + ModelAlias, + CursorModelId, + CodexModelId, + OpencodeModelId, + GroupedModel, + PhaseModelEntry, +} from '@automaker/types'; +import { + stripProviderPrefix, + STANDALONE_CURSOR_MODELS, + getModelGroup, + isGroupSelected, + getSelectedVariant, + codexModelHasThinking, +} from '@automaker/types'; +import { + CLAUDE_MODELS, + CURSOR_MODELS, + OPENCODE_MODELS, + THINKING_LEVELS, + THINKING_LEVEL_LABELS, + REASONING_EFFORT_LEVELS, + REASONING_EFFORT_LABELS, + type ModelOption, +} from '@/components/views/board-view/shared/model-constants'; +import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + getProviderIconForModel, +} from '@/components/ui/provider-icon'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI'; +const OPENCODE_PROVIDER_FALLBACK = 'opencode'; +const OPENCODE_PROVIDER_WORD_SEPARATOR = '-'; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_SECTION_GROUP_PADDING = 'pt-2'; + +const OPENCODE_STATIC_PROVIDER_LABELS: Record = { + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = { + 'github-copilot': 'GitHub Copilot', + 'zai-coding-plan': 'Z.AI Coding Plan', + google: 'Google AI', + openai: 'OpenAI', + openrouter: 'OpenRouter', + anthropic: 'Anthropic', + xai: 'xAI', + deepseek: 'DeepSeek', + ollama: 'Ollama (Local)', + lmstudio: 'LM Studio (Local)', + azure: 'Azure OpenAI', + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_ORDER = [ + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'anthropic', + 'xai', + 'deepseek', + 'ollama', + 'lmstudio', + 'azure', + 'zai-coding-plan', +]; + +const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const; + +const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = { + free: 'Free Tier', + dynamic: 'Connected Providers', +}; + +const OPENCODE_STATIC_PROVIDER_BY_ID = new Map( + OPENCODE_MODELS.map((model) => [model.id, model.provider]) +); + +function formatProviderLabel(providerKey: string): string { + return providerKey + .split(OPENCODE_PROVIDER_WORD_SEPARATOR) + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] { + if (providerKey === OPENCODE_PROVIDER_FALLBACK) { + return 'free'; + } + return 'dynamic'; +} + +function getOpencodeGroupLabel( + providerKey: string, + sectionKey: (typeof OPENCODE_SECTION_ORDER)[number] +): string { + if (sectionKey === 'free') { + return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier'; + } + return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey); +} + +interface PhaseModelSelectorProps { + /** Label shown in full mode */ + label?: string; + /** Description shown in full mode */ + description?: string; + /** Current model selection */ + value: PhaseModelEntry; + /** Callback when model is selected */ + onChange: (entry: PhaseModelEntry) => void; + /** Compact mode - just shows the button trigger without label/description wrapper */ + compact?: boolean; + /** Custom trigger class name */ + triggerClassName?: string; + /** Popover alignment */ + align?: 'start' | 'end'; + /** Disabled state */ + disabled?: boolean; +} + +export function PhaseModelSelector({ + label, + description, + value, + onChange, + compact = false, + triggerClassName, + align = 'end', + disabled = false, +}: PhaseModelSelectorProps) { + const [open, setOpen] = useState(false); + const [expandedGroup, setExpandedGroup] = useState(null); + const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); + const [expandedCodexModel, setExpandedCodexModel] = useState(null); + const commandListRef = useRef(null); + const expandedTriggerRef = useRef(null); + const expandedClaudeTriggerRef = useRef(null); + const expandedCodexTriggerRef = useRef(null); + const { + enabledCursorModels, + favoriteModels, + toggleFavoriteModel, + codexModels, + codexModelsLoading, + fetchCodexModels, + dynamicOpencodeModels, + enabledDynamicModelIds, + opencodeModelsLoading, + fetchOpencodeModels, + disabledProviders, + } = useAppStore(); + + // Detect mobile devices to use inline expansion instead of nested popovers + const isMobile = useIsMobile(); + + // Extract model and thinking/reasoning levels from value + const selectedModel = value.model; + const selectedThinkingLevel = value.thinkingLevel || 'none'; + const selectedReasoningEffort = value.reasoningEffort || 'none'; + + // Fetch Codex models on mount + useEffect(() => { + if (codexModels.length === 0 && !codexModelsLoading) { + fetchCodexModels().catch(() => { + // Silently fail - user will see empty Codex section + }); + } + }, [codexModels.length, codexModelsLoading, fetchCodexModels]); + + // Fetch OpenCode models on mount + useEffect(() => { + if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { + fetchOpencodeModels().catch(() => { + // Silently fail - user will see only static OpenCode models + }); + } + }, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); + + // Close expanded group when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedGroup) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedGroup(null); + } + }, + { + root: listElement, + threshold: 0.1, // Close when less than 10% visible + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedGroup]); + + // Close expanded Claude model popover when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedClaudeTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedClaudeModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedClaudeModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedClaudeModel]); + + // Close expanded Codex model popover when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedCodexTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedCodexModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedCodexModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedCodexModel]); + + // Transform dynamic Codex models from store to component format + const transformedCodexModels = useMemo(() => { + return codexModels.map((model) => ({ + id: model.id, + label: model.label, + description: model.description, + provider: 'codex' as const, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined, + })); + }, [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) => { + return enabledCursorModels.includes(model.id as CursorModelId); + }); + + // Helper to find current selected model details + const currentModel = useMemo(() => { + const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); + if (claudeModel) { + // Add thinking level to label if not 'none' + const thinkingLabel = + selectedThinkingLevel !== 'none' + ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` + : ''; + return { + ...claudeModel, + label: `${claudeModel.label}${thinkingLabel}`, + icon: AnthropicIcon, + }; + } + + // With canonical IDs, direct comparison works + const cursorModel = availableCursorModels.find((m) => m.id === selectedModel); + if (cursorModel) return { ...cursorModel, icon: CursorIcon }; + + // Check if selectedModel is part of a grouped model + const group = getModelGroup(selectedModel as CursorModelId); + if (group) { + const variant = getSelectedVariant(group, selectedModel as CursorModelId); + return { + id: selectedModel, + label: `${group.label} (${variant?.label || 'Unknown'})`, + description: group.description, + provider: 'cursor' as const, + icon: CursorIcon, + }; + } + + // Check Codex models + const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); + if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + + // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons + const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); + if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; + + // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons + const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel); + if (dynamicModel) { + return { + id: dynamicModel.id, + label: dynamicModel.name, + description: dynamicModel.description, + provider: 'opencode' as const, + icon: getProviderIconForModel(dynamicModel.id), + }; + } + + return null; + }, [ + selectedModel, + selectedThinkingLevel, + availableCursorModels, + transformedCodexModels, + dynamicOpencodeModels, + ]); + + // Compute grouped vs standalone Cursor models + const { groupedModels, standaloneCursorModels } = useMemo(() => { + const grouped: GroupedModel[] = []; + const standalone: typeof CURSOR_MODELS = []; + const seenGroups = new Set(); + + availableCursorModels.forEach((model) => { + const cursorId = model.id as CursorModelId; + + // Check if this model is standalone + if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { + standalone.push(model); + return; + } + + // Check if this model belongs to a group + const group = getModelGroup(cursorId); + if (group && !seenGroups.has(group.baseId)) { + // Filter variants to only include enabled models + const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id)); + if (enabledVariants.length > 0) { + grouped.push({ + ...group, + variants: enabledVariants, + }); + seenGroups.add(group.baseId); + } + } + }); + + return { groupedModels: grouped, standaloneCursorModels: standalone }; + }, [availableCursorModels, enabledCursorModels]); + + // Combine static and dynamic OpenCode models + const allOpencodeModels: ModelOption[] = useMemo(() => { + // Start with static models + const staticModels = [...OPENCODE_MODELS]; + + // Add dynamic models (convert ModelDefinition to ModelOption) + // Only include dynamic models that are enabled by the user + const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels + .filter((model) => enabledDynamicModelIds.includes(model.id)) + .map((model) => ({ + id: model.id, + label: model.name, + description: model.description, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined, + provider: 'opencode' as const, + })); + + // Merge, avoiding duplicates (static models take precedence for same ID) + // In practice, static and dynamic IDs don't overlap + const staticIds = new Set(staticModels.map((m) => m.id)); + const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id)); + + return [...staticModels, ...uniqueDynamic]; + }, [dynamicOpencodeModels, enabledDynamicModelIds]); + + // Group models (filtering out disabled providers) + const { favorites, claude, cursor, codex, opencode } = useMemo(() => { + const favs: typeof CLAUDE_MODELS = []; + const cModels: typeof CLAUDE_MODELS = []; + const curModels: typeof CURSOR_MODELS = []; + const codModels: typeof transformedCodexModels = []; + const ocModels: ModelOption[] = []; + + const isClaudeDisabled = disabledProviders.includes('claude'); + const isCursorDisabled = disabledProviders.includes('cursor'); + const isCodexDisabled = disabledProviders.includes('codex'); + const isOpencodeDisabled = disabledProviders.includes('opencode'); + + // Process Claude Models (skip if provider is disabled) + if (!isClaudeDisabled) { + CLAUDE_MODELS.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + cModels.push(model); + } + }); + } + + // Process Cursor Models (skip if provider is disabled) + if (!isCursorDisabled) { + availableCursorModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + curModels.push(model); + } + }); + } + + // Process Codex Models (skip if provider is disabled) + if (!isCodexDisabled) { + transformedCodexModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + codModels.push(model); + } + }); + } + + // Process OpenCode Models (skip if provider is disabled) + if (!isOpencodeDisabled) { + allOpencodeModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + ocModels.push(model); + } + }); + } + + return { + favorites: favs, + claude: cModels, + cursor: curModels, + codex: codModels, + opencode: ocModels, + }; + }, [ + favoriteModels, + availableCursorModels, + transformedCodexModels, + allOpencodeModels, + disabledProviders, + ]); + + // Group OpenCode models by model type for better organization + const opencodeSections = useMemo(() => { + type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number]; + type OpencodeGroup = { key: string; label: string; models: ModelOption[] }; + type OpencodeSection = { + key: OpencodeSectionKey; + label: string; + showGroupLabels: boolean; + groups: OpencodeGroup[]; + }; + + const sections: Record> = { + free: {}, + dynamic: {}, + }; + const dynamicProviderById = new Map( + dynamicOpencodeModels.map((model) => [model.id, model.provider]) + ); + + const resolveProviderKey = (modelId: string): string => { + const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId); + if (staticProvider) return staticProvider; + + const dynamicProvider = dynamicProviderById.get(modelId); + if (dynamicProvider) return dynamicProvider; + + return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR) + ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0] + : OPENCODE_PROVIDER_FALLBACK; + }; + + const addModelToGroup = ( + sectionKey: OpencodeSectionKey, + providerKey: string, + model: ModelOption + ) => { + if (!sections[sectionKey][providerKey]) { + sections[sectionKey][providerKey] = { + key: providerKey, + label: getOpencodeGroupLabel(providerKey, sectionKey), + models: [], + }; + } + sections[sectionKey][providerKey].models.push(model); + }; + + opencode.forEach((model) => { + const providerKey = resolveProviderKey(model.id); + const sectionKey = getOpencodeSectionKey(providerKey); + addModelToGroup(sectionKey, providerKey, model); + }); + + const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => { + const groupMap = sections[sectionKey]; + const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : []; + const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index])); + + return Object.keys(groupMap) + .sort((a, b) => { + const aPriority = priorityMap.get(a); + const bPriority = priorityMap.get(b); + + if (aPriority !== undefined && bPriority !== undefined) { + return aPriority - bPriority; + } + if (aPriority !== undefined) return -1; + if (bPriority !== undefined) return 1; + + return groupMap[a].label.localeCompare(groupMap[b].label); + }) + .map((key) => groupMap[key]); + }; + + const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => { + const groups = buildGroupList(sectionKey); + if (groups.length === 0) return null; + + return { + key: sectionKey, + label: OPENCODE_SECTION_LABELS[sectionKey], + showGroupLabels: sectionKey !== 'free', + groups, + }; + }).filter(Boolean) as OpencodeSection[]; + + return builtSections; + }, [opencode, dynamicOpencodeModels]); + + // Render Codex model item with secondary popover for reasoning effort (only for models that support it) + const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + const hasReasoning = codexModelHasThinking(model.id as CodexModelId); + const isExpanded = expandedCodexModel === model.id; + const currentReasoning = isSelected ? selectedReasoningEffort : 'none'; + + // If model doesn't support reasoning, render as simple selector (like Cursor models) + if (!hasReasoning) { + return ( + { + onChange({ model: model.id as CodexModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + } + + // Model supports reasoning - show popover with reasoning effort options + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline reasoning effort options on mobile */} + {isExpanded && ( +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedCodexModel(null); + } + }} + > + +
+
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+
+
+
+ ); + }; + + // Render OpenCode model item (simple selector, no thinking/reasoning options) + const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + // Get the appropriate icon based on the specific model ID + const ProviderIcon = getProviderIconForModel(model.id); + + return ( + { + onChange({ model: model.id as OpencodeModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ {model.badge && ( + + {model.badge} + + )} + + {isSelected && } +
+
+ ); + }; + + // Render Cursor model item (no thinking level needed) + const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { + // With canonical IDs, store the full prefixed ID + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as CursorModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + }; + + // Render Claude model item with secondary popover for thinking level + const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + const isExpanded = expandedClaudeModel === model.id; + const currentThinking = isSelected ? selectedThinkingLevel : 'none'; + + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline thinking level options on mobile */} + {isExpanded && ( +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedClaudeModel(null); + } + }} + > + +
+
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+
+
+
+ ); + }; + + // Render a grouped model with secondary popover for variant selection + const renderGroupedModelItem = (group: GroupedModel) => { + const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId); + const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId); + const isExpanded = expandedGroup === group.baseId; + + const variantTypeLabel = + group.variantType === 'compute' + ? 'Compute Level' + : group.variantType === 'thinking' + ? 'Reasoning Mode' + : 'Capacity Options'; + + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedGroup(isExpanded ? null : group.baseId)} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && !isExpanded && ( + + )} + +
+
+ + {/* Inline variant options on mobile */} + {isExpanded && ( +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedGroup(isExpanded ? null : group.baseId)} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedGroup(null); + } + }} + > + +
+
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+
+
+
+ ); + }; + + // Compact trigger button (for agent view etc.) + const compactTrigger = ( + + ); + + // Full trigger button (for settings view) + const fullTrigger = ( + + ); + + // The popover content (shared between both modes) + const popoverContent = ( + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + onPointerDownOutside={(e) => { + // Only prevent close if clicking inside a nested popover (thinking level panel) + const target = e.target as HTMLElement; + if (target.closest('[data-slot="popover-content"]')) { + e.preventDefault(); + } + }} + > + + + + No model found. + + {favorites.length > 0 && ( + <> + + {(() => { + const renderedGroups = new Set(); + return favorites.map((model) => { + // Check if this favorite is part of a grouped model + if (model.provider === 'cursor') { + const cursorId = model.id as CursorModelId; + const group = getModelGroup(cursorId); + if (group) { + // Skip if we already rendered this group + if (renderedGroups.has(group.baseId)) { + return null; + } + renderedGroups.add(group.baseId); + // Find the group in groupedModels (which has filtered variants) + const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId); + if (filteredGroup) { + return renderGroupedModelItem(filteredGroup); + } + } + // Standalone Cursor model + return renderCursorModelItem(model); + } + // Codex model + if (model.provider === 'codex') { + return renderCodexModelItem(model as (typeof transformedCodexModels)[0]); + } + // OpenCode model + if (model.provider === 'opencode') { + return renderOpencodeModelItem(model); + } + // Claude model + return renderClaudeModelItem(model); + }); + })()} + + + + )} + + {claude.length > 0 && ( + + {claude.map((model) => renderClaudeModelItem(model))} + + )} + + {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( + + {/* Grouped models with secondary popover */} + {groupedModels.map((group) => renderGroupedModelItem(group))} + {/* Standalone models */} + {standaloneCursorModels.map((model) => renderCursorModelItem(model))} + + )} + + {codex.length > 0 && ( + + {codex.map((model) => renderCodexModelItem(model))} + + )} + + {opencodeSections.length > 0 && ( + + {opencodeSections.map((section, sectionIndex) => ( + +
+ {section.label} +
+
+ {section.groups.map((group) => ( +
+ {section.showGroupLabels && ( +
+ {group.label} +
+ )} + {group.models.map((model) => renderOpencodeModelItem(model))} +
+ ))} +
+
+ ))} +
+ )} +
+
+
+ ); + + // Compact mode - just the popover with compact trigger + if (compact) { + return ( + + {compactTrigger} + {popoverContent} + + ); + } + + // Full mode - with label and description wrapper + return ( +
+ {/* Label and Description */} +
+

{label}

+

{description}

+
+ + {/* Model Selection Popover */} + + {fullTrigger} + {popoverContent} + +
+ ); +}