Merge remote-tracking branch 'upstream/v0.13.0rc' into feat/react-query

# Conflicts:
#	apps/ui/src/components/views/board-view.tsx
#	apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
#	apps/ui/src/components/views/board-view/hooks/use-board-features.ts
#	apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
#	apps/ui/src/hooks/use-project-settings-loader.ts
This commit is contained in:
DhanushSantosh
2026-01-20 19:19:21 +05:30
76 changed files with 5995 additions and 492 deletions

View File

@@ -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<string, { createdAt: number; expiresAt: number }>();
@@ -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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -447,5 +462,6 @@ export function checkRawAuthentication(
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

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

View File

@@ -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<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
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

View File

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

View File

@@ -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<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { 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';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -123,6 +127,13 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]',
projectPath
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
@@ -134,6 +145,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel,
readOnly: true, // Feature generation 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
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

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

View File

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

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
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');

View File

@@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
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');

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
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');

View File

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

View File

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

View File

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

View File

@@ -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<void> {
return async (req: Request, res: Response): Promise<void> => {
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;

View File

@@ -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<void> {
return async (req: Request, res: Response): Promise<void> => {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> {
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<void> {
@@ -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 = {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -147,6 +147,7 @@
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"publish": null,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"directories": {
"output": "release"

View File

@@ -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<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
onMouseEnter={handleThemeMenuEnter}
onMouseLeave={handleThemeMenuLeave}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'absolute left-full ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
style={{
zIndex: Z_INDEX.THEME_SUBMENU,
top: `${submenuPosition.top}px`,
}}
data-testid="project-theme-submenu"
onMouseEnter={handleThemeMenuEnter}
onMouseLeave={handleThemeMenuLeave}
>
<div className="p-2">
{/* Use Global Option */}
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
{/* Dynamic max height with scroll for viewport overflow handling */}
<div
className="flex gap-2 overflow-y-auto scrollbar-styled"
style={{
maxHeight: `${Math.max(0, submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT)}px`,
}}
>
<ThemeColumn
title="Dark"
icon={Moon}

View File

@@ -30,17 +30,41 @@ import {
import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, THEME_SUBMENU_CONSTANTS } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
/**
* Props for the ProjectSelectorWithOptions component.
* Defines the interface for the project selector dropdown with additional options menu.
*/
interface ProjectSelectorWithOptionsProps {
/** Whether the sidebar is currently expanded */
sidebarOpen: boolean;
/** Whether the project picker dropdown is currently open */
isProjectPickerOpen: boolean;
/** Callback to control the project picker dropdown open state */
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void;
}
/**
* A project selector component with search, drag-and-drop reordering, and options menu.
*
* Features:
* - Searchable dropdown for quick project switching
* - Drag-and-drop reordering of projects
* - Project-specific theme selection with live preview
* - Project history navigation (previous/next)
* - Option to move project to trash
*
* The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS
* for consistent submenu behavior across the application.
*
* @param props - Component props
* @returns The rendered project selector or null if sidebar is closed or no projects exist
*/
export function ProjectSelectorWithOptions({
sidebarOpen,
isProjectPickerOpen,
@@ -246,6 +270,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuSubContent
className="w-[420px] bg-popover/95 backdrop-blur-xl"
data-testid="project-theme-menu"
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
</div>
<DropdownMenuSeparator />
{/* Two Column Layout */}
<div className="flex gap-2 p-2">
{/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
{/* Dark Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">

View File

@@ -1,5 +1,36 @@
import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components
* to ensure consistent viewport-aware positioning and styling.
*/
export const THEME_SUBMENU_CONSTANTS = {
/**
* Estimated total height of the theme submenu content in pixels.
* Includes all theme options, headers, padding, and "Use Global" button.
*/
ESTIMATED_SUBMENU_HEIGHT: 620,
/**
* Padding from viewport edges to prevent submenu overflow.
* Applied to both top and bottom edges when calculating available space.
*/
COLLISION_PADDING: 32,
/**
* Vertical offset from context menu top to the "Project Theme" button.
* Used for calculating submenu position relative to trigger button.
*/
THEME_BUTTON_OFFSET: 50,
/**
* Height reserved for submenu header area (includes "Use Global" button and separator).
* Subtracted from maxHeight to get scrollable content area height.
*/
SUBMENU_HEADER_HEIGHT: 80,
} as const;
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
value: opt.value,
label: opt.label,

View File

@@ -93,8 +93,8 @@ const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
maxConcurrency,
setMaxConcurrency,
maxConcurrency: legacyMaxConcurrency,
setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests,
specCreatingForProject,
setSpecCreatingForProject,
@@ -275,10 +275,24 @@ export function BoardView() {
setFeaturesWithContext,
});
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
const runningAutoTasks = autoMode.runningTasks;
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
logger.error('Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
@@ -388,14 +402,6 @@ export function BoardView() {
[hookFeatures, updateFeature, persistFeatureUpdate]
);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
@@ -421,6 +427,16 @@ export function BoardView() {
}
}, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree ?? undefined);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
const maxConcurrency = autoMode.maxConcurrency;
// Get worktree-specific setter
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -429,6 +445,15 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
@@ -526,14 +551,14 @@ export function BoardView() {
try {
// Determine final branch name based on work mode:
// - 'current': Empty string to clear branch assignment (work on main/current branch)
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = '';
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -619,6 +644,7 @@ export function BoardView() {
exitSelectionMode,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
currentWorktreeBranch,
setWorktreeRefreshKey,
]
);
@@ -1139,7 +1165,21 @@ export function BoardView() {
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Also update backend if auto mode is running
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
autoMode.stop().then(() => {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
});
}
}
}}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
@@ -1406,6 +1446,7 @@ export function BoardView() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
branchName={outputFeature?.branchName}
/>
{/* Archive All Verified Dialog */}

View File

@@ -182,6 +182,13 @@ export function BoardHeader({
>
Auto Mode
</Label>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}

View File

@@ -29,6 +29,8 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
/** Branch name for the feature worktree - used when viewing changes */
branchName?: string;
}
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
@@ -41,6 +43,7 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
branchName,
}: AgentOutputModalProps) {
const isBacklogPlan = featureId.startsWith('backlog-plan:');
@@ -404,7 +407,7 @@ export function AgentOutputModal({
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={resolvedProjectPath}
featureId={featureId}
featureId={branchName || featureId}
compact={false}
useWorktrees={useWorktrees}
className="border-0 rounded-lg"

View File

@@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';

View File

@@ -0,0 +1,68 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FileText } from 'lucide-react';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface ViewWorktreeChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string;
}
export function ViewWorktreeChangesDialog({
open,
onOpenChange,
worktree,
projectPath,
}: ViewWorktreeChangesDialogProps) {
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
View Changes
</DialogTitle>
<DialogDescription>
Changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
<GitDiffPanel
projectPath={projectPath}
featureId={worktree.branch}
useWorktrees={true}
compact={false}
className="mt-4"
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="mobile-auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
</div>
<div className="flex items-center gap-2">
<Switch

View File

@@ -123,14 +123,15 @@ export function useBoardActions({
const workMode = featureData.workMode || 'current';
// Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree)
// - 'current': Use current worktree's branch (or undefined if on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// No worktree isolation - work directly on current branch
finalBranchName = undefined;
// Work directly on current branch - use the current worktree's branch if not on main
// This ensures features created on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -217,7 +218,7 @@ export function useBoardActions({
const api = getElectronAPI();
if (api?.features?.generateTitle) {
api.features
.generateTitle(featureData.description)
.generateTitle(featureData.description, projectPath ?? undefined)
.then((result) => {
if (result.success && result.title) {
const titleUpdates = {
@@ -250,10 +251,12 @@ export function useBoardActions({
updateFeature,
saveCategory,
currentProject,
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);
@@ -287,7 +290,9 @@ export function useBoardActions({
let finalBranchName: string | undefined;
if (workMode === 'current') {
finalBranchName = undefined;
// Work directly on current branch - use the current worktree's branch if not on main
// This ensures features updated on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -402,6 +407,7 @@ export function useBoardActions({
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);

View File

@@ -103,8 +103,25 @@ export function useBoardColumnFeatures({
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
// it's already running for the current worktree. However, we still need to check matchesWorktree
// to ensure the feature's branchName matches the current worktree's branch.
if (isRunning) {
if (!matchesWorktree) return;
// If feature is running but doesn't match worktree, it might be a timing issue where
// the feature was started for a different worktree. Still show it if it's running to
// prevent disappearing features, but log a warning.
if (!matchesWorktree) {
// This can happen if:
// 1. Feature was started for a different worktree (bug)
// 2. Timing issue where branchName hasn't been set yet
// 3. User switched worktrees while feature was starting
// Still show it in in_progress to prevent it from disappearing
console.debug(
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
);
map.in_progress.push(f);
return;
}
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];

View File

@@ -50,7 +50,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
} catch {
setPersistedCategories([]);
}
}, [currentProject]);
}, [currentProject, loadFeatures]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
@@ -87,11 +87,33 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return;
}
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_complete') {
if (event.type === 'auto_mode_feature_start') {
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
logger.info(
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
);
loadFeatures();
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Get current project path for per-project Claude API profile
const currentProjectPath = useAppStore((state) => state.currentProject?.path);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
enhancementOverride.effectiveModelEntry.thinkingLevel,
currentProjectPath
);
if (result?.success && result.enhancedText) {

View File

@@ -132,11 +132,12 @@ export function DevServerLogsPanel({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
data-testid="dev-server-logs-panel"
compact
>
{/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" />

View File

@@ -25,10 +25,13 @@ import {
AlertCircle,
RefreshCw,
Copy,
Eye,
ScrollText,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
Undo2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -56,12 +59,16 @@ interface WorktreeActionsDropdownProps {
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -73,6 +80,7 @@ interface WorktreeActionsDropdownProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -88,12 +96,15 @@ export function WorktreeActionsDropdown({
devServerInfo,
gitRepoStatus,
standalone = false,
isAutoModeRunning = false,
onOpenChange,
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -105,6 +116,7 @@ export function WorktreeActionsDropdown({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -214,6 +226,26 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPull(worktree)}
@@ -408,6 +440,13 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
)}
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
@@ -483,9 +522,30 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuItem
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs text-destructive focus:text-destructive',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Undo2 className="w-3.5 h-3.5 mr-2" />
Discard Changes
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"

View File

@@ -29,6 +29,8 @@ interface WorktreeTabProps {
aheadCount: number;
behindCount: number;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -40,6 +42,8 @@ interface WorktreeTabProps {
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -51,6 +55,7 @@ interface WorktreeTabProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -75,6 +80,7 @@ export function WorktreeTab({
aheadCount,
behindCount,
gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -86,6 +92,8 @@ export function WorktreeTab({
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -97,6 +105,7 @@ export function WorktreeTab({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
@@ -332,6 +341,26 @@ export function WorktreeTab({
</TooltipProvider>
)}
{isAutoModeRunning && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
@@ -343,12 +372,15 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunning}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -360,6 +392,7 @@ export function WorktreeTab({
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
@@ -22,6 +22,10 @@ import {
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react';
export function WorktreePanel({
projectPath,
@@ -51,7 +55,6 @@ export function WorktreePanel({
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
@@ -90,10 +93,79 @@ export function WorktreePanel({
features,
});
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
return `${projectId}::${branchName ?? '__main__'}`;
},
[]
);
// Helper to check if auto-mode is running for a specific worktree
const isAutoModeRunningForWorktree = useCallback(
(worktree: WorktreeInfo): boolean => {
if (!currentProject) return false;
const branchName = worktree.isMain ? null : worktree.branch;
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
return autoModeByWorktree[key]?.isRunning ?? false;
},
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
);
// Handler to toggle auto-mode for a worktree
const handleToggleAutoMode = useCallback(
async (worktree: WorktreeInfo) => {
if (!currentProject) return;
// Import the useAutoMode to get start/stop functions
// Since useAutoMode is a hook, we'll use the API client directly
const api = getHttpApiClient();
const branchName = worktree.isMain ? null : worktree.branch;
const isRunning = isAutoModeRunningForWorktree(worktree);
try {
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode stopped for ${desc}`);
} else {
toast.error(result.error || 'Failed to stop Auto Mode');
}
} else {
const result = await api.autoMode.start(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
toast.error(result.error || 'Failed to start Auto Mode');
}
}
} catch (error) {
toast.error('Error toggling Auto Mode');
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Check if init script exists for the project using React Query
const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
// Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
@@ -161,6 +233,41 @@ export function WorktreePanel({
[projectPath]
);
const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
setViewChangesWorktree(worktree);
setViewChangesDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
}, []);
const handleConfirmDiscardChanges = useCallback(async () => {
if (!discardChangesWorktree) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
if (result.success) {
toast.success('Changes discarded', {
description: `Discarded changes in ${discardChangesWorktree.branch}`,
});
// Refresh worktrees to update the changes status
fetchWorktrees({ silent: true });
} else {
toast.error('Failed to discard changes', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to discard changes', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [discardChangesWorktree, fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -224,12 +331,15 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -241,6 +351,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -274,6 +385,36 @@ export function WorktreePanel({
</Button>
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
onClose={handleCloseLogPanel}
worktree={logPanelWorktree}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</div>
);
}
@@ -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 */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}

View File

@@ -432,6 +432,7 @@ export function GraphViewPage() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
branchName={outputFeature?.branchName}
/>
{/* Backlog Plan Dialog */}

View File

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

View File

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

View File

@@ -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 (
<div className="text-center py-12 text-muted-foreground">
<Bot className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Claude not configured</p>
<p className="text-xs mt-1">
Enable Claude and configure API profiles in global settings to use per-project profiles.
</p>
</div>
);
}
// 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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Bot className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude API Profile
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Override the Claude API profile for this project only.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Active Profile for This Project</Label>
<Select value={selectValue} onValueChange={handleChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<span>Use Global Setting</span>
<span className="text-xs text-muted-foreground ml-1">({globalProfileName})</span>
</div>
</SelectItem>
<SelectItem value="direct">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-brand-500" />
<span>Direct Anthropic API</span>
</div>
</SelectItem>
{claudeApiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-muted-foreground" />
<span>{profile.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{getDisplayText()}</p>
</div>
{/* Info about what this affects */}
<div className="text-xs text-muted-foreground/70 pt-2 border-t border-border/30">
<p>This setting affects all Claude operations for this project including:</p>
<ul className="list-disc list-inside mt-1 space-y-0.5">
<li>Agent chat and feature implementation</li>
<li>Code analysis and suggestions</li>
<li>Commit message generation</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -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 <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'claude':
return <ProjectClaudeSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -223,6 +223,7 @@ export function RunningAgentsView() {
}
featureId={selectedAgent.featureId}
featureStatus="running"
branchName={selectedAgent.branchName}
/>
)}
</div>

View File

@@ -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() {
</p>
</div>
<div className="p-6 space-y-6">
{/* API Key Fields */}
{/* API Key Fields with contextual info */}
{providerConfigs.map((provider) => (
<ApiKeyField key={provider.key} config={provider} />
<div key={provider.key}>
<ApiKeyField config={provider} />
{/* Anthropic-specific profile info */}
{provider.key === 'anthropic' && (
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<span className="font-medium text-foreground/80">
Using Claude API Profiles?
</span>{' '}
Create a profile in{' '}
<span className="text-blue-500">AI Providers Claude</span> with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">
credentials
</span>{' '}
as the API key source to use this key.
</p>
<p>
For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile
with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '}
key source and enter the provider's API key directly in the profile.
</p>
</div>
</div>
</div>
)}
</div>
))}
{/* Security Notice */}

View File

@@ -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 */}
<ApiProfilesSection />
<ClaudeMdSettings
autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}

View File

@@ -0,0 +1,638 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import {
Cloud,
Eye,
EyeOff,
ExternalLink,
MoreVertical,
Pencil,
Plus,
Server,
Trash2,
Zap,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ClaudeApiProfile, ApiKeySource } from '@automaker/types';
import { CLAUDE_API_PROFILE_TEMPLATES } from '@automaker/types';
// Generate unique ID for profiles
function generateProfileId(): string {
return `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Mask API key for display (show first 4 + last 4 chars)
function maskApiKey(key?: string): string {
if (!key || key.length <= 8) return '••••••••';
return `${key.substring(0, 4)}••••${key.substring(key.length - 4)}`;
}
interface ProfileFormData {
name: string;
baseUrl: string;
apiKeySource: ApiKeySource;
apiKey: string;
useAuthToken: boolean;
timeoutMs: string; // String for input, convert to number
modelMappings: {
haiku: string;
sonnet: string;
opus: string;
};
disableNonessentialTraffic: boolean;
}
const emptyFormData: ProfileFormData = {
name: '',
baseUrl: '',
apiKeySource: 'inline',
apiKey: '',
useAuthToken: false,
timeoutMs: '',
modelMappings: {
haiku: '',
sonnet: '',
opus: '',
},
disableNonessentialTraffic: false,
};
export function ApiProfilesSection() {
const {
claudeApiProfiles,
activeClaudeApiProfileId,
addClaudeApiProfile,
updateClaudeApiProfile,
deleteClaudeApiProfile,
setActiveClaudeApiProfile,
} = useAppStore();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [formData, setFormData] = useState<ProfileFormData>(emptyFormData);
const [showApiKey, setShowApiKey] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Server className="w-5 h-5 text-brand-500" />
</div>
<div>
<h3 className="font-semibold text-foreground">API Profiles</h3>
<p className="text-xs text-muted-foreground">Manage Claude-compatible API endpoints</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Profile
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenAddDialog()}>
<Plus className="w-4 h-4 mr-2" />
Custom Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
{CLAUDE_API_PROFILE_TEMPLATES.map((template) => (
<DropdownMenuItem
key={template.name}
onClick={() => handleOpenAddDialog(template.name)}
>
<Zap className="w-4 h-4 mr-2" />
{template.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Active Profile Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">Active Profile</Label>
<Select
value={activeClaudeApiProfileId ?? 'none'}
onValueChange={(value) => setActiveClaudeApiProfile(value === 'none' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select active profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-brand-500" />
Direct Anthropic API
</div>
</SelectItem>
{claudeApiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-muted-foreground" />
{profile.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{activeClaudeApiProfileId
? 'Using custom API endpoint'
: 'Using direct Anthropic API (API key or Claude Max plan)'}
</p>
</div>
{/* Profile List */}
{claudeApiProfiles.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border border-dashed border-border/50 rounded-lg">
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No API profiles configured</p>
<p className="text-xs mt-1">
Add a profile to use alternative Claude-compatible endpoints
</p>
</div>
) : (
<div className="space-y-3">
{claudeApiProfiles.map((profile) => (
<ProfileCard
key={profile.id}
profile={profile}
isActive={profile.id === activeClaudeApiProfileId}
onEdit={() => handleOpenEditDialog(profile)}
onDelete={() => setDeleteConfirmId(profile.id)}
onSetActive={() => setActiveClaudeApiProfile(profile.id)}
/>
))}
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit API Profile' : 'Add API Profile'}</DialogTitle>
<DialogDescription>
Configure a Claude-compatible API endpoint. API keys are stored locally.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., z.AI GLM"
className={isDuplicateName ? 'border-destructive' : ''}
/>
{isDuplicateName && (
<p className="text-xs text-destructive">A profile with this name already exists</p>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label htmlFor="profile-base-url">API Base URL</Label>
<Input
id="profile-base-url"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
{/* API Key Source */}
<div className="space-y-2">
<Label>API Key Source</Label>
<Select
value={formData.apiKeySource}
onValueChange={(value: ApiKeySource) =>
setFormData({ ...formData, apiKeySource: value })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select API key source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="credentials">
Use saved API key (from Settings API Keys)
</SelectItem>
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
<SelectItem value="inline">Enter key for this profile only</SelectItem>
</SelectContent>
</Select>
{formData.apiKeySource === 'credentials' && (
<p className="text-xs text-muted-foreground">
Will use the Anthropic key from Settings API Keys
</p>
)}
{formData.apiKeySource === 'env' && (
<p className="text-xs text-muted-foreground">
Will use ANTHROPIC_API_KEY environment variable
</p>
)}
</div>
{/* API Key (only shown for inline source) */}
{formData.apiKeySource === 'inline' && (
<div className="space-y-2">
<Label htmlFor="profile-api-key">API Key</Label>
<div className="relative">
<Input
id="profile-api-key"
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
{currentTemplate?.apiKeyUrl && (
<a
href={currentTemplate.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
>
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
{/* Use Auth Token */}
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="use-auth-token" className="font-medium">
Use Auth Token
</Label>
<p className="text-xs text-muted-foreground">
Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY
</p>
</div>
<Switch
id="use-auth-token"
checked={formData.useAuthToken}
onCheckedChange={(checked) => setFormData({ ...formData, useAuthToken: checked })}
/>
</div>
{/* Timeout */}
<div className="space-y-2">
<Label htmlFor="profile-timeout">Timeout (ms)</Label>
<Input
id="profile-timeout"
type="number"
value={formData.timeoutMs}
onChange={(e) => setFormData({ ...formData, timeoutMs: e.target.value })}
placeholder="Optional, e.g., 3000000"
/>
</div>
{/* Model Mappings */}
<div className="space-y-3">
<Label className="font-medium">Model Mappings (Optional)</Label>
<p className="text-xs text-muted-foreground -mt-1">
Map Claude model aliases to provider-specific model names
</p>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="model-haiku" className="text-xs">
Haiku
</Label>
<Input
id="model-haiku"
value={formData.modelMappings.haiku}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, haiku: e.target.value },
})
}
placeholder="e.g., GLM-4.5-Flash"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="model-sonnet" className="text-xs">
Sonnet
</Label>
<Input
id="model-sonnet"
value={formData.modelMappings.sonnet}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, sonnet: e.target.value },
})
}
placeholder="e.g., glm-4.7"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="model-opus" className="text-xs">
Opus
</Label>
<Input
id="model-opus"
value={formData.modelMappings.opus}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, opus: e.target.value },
})
}
placeholder="e.g., glm-4.7"
className="text-xs"
/>
</div>
</div>
</div>
{/* Disable Non-essential Traffic */}
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="disable-traffic" className="font-medium">
Disable Non-essential Traffic
</Label>
<p className="text-xs text-muted-foreground">
Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
</p>
</div>
<Switch
id="disable-traffic"
checked={formData.disableNonessentialTraffic}
onCheckedChange={(checked) =>
setFormData({ ...formData, disableNonessentialTraffic: checked })
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isFormValid}>
{editingProfileId ? 'Save Changes' : 'Add Profile'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmId} onOpenChange={(open) => !open && setDeleteConfirmId(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Profile?</DialogTitle>
<DialogDescription>
This will permanently delete the API profile. If this profile is currently active, you
will be switched to direct Anthropic API.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface ProfileCardProps {
profile: ClaudeApiProfile;
isActive: boolean;
onEdit: () => void;
onDelete: () => void;
onSetActive: () => void;
}
function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) {
return (
<div
className={cn(
'rounded-lg border p-4 transition-colors',
isActive
? 'border-brand-500/50 bg-brand-500/5'
: 'border-border/50 bg-card/50 hover:border-border'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-foreground truncate">{profile.name}</h4>
{isActive && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-500">
Active
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-1">{profile.baseUrl}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-muted-foreground">
<span>Key: {maskApiKey(profile.apiKey)}</span>
{profile.useAuthToken && <span>Auth Token</span>}
{profile.timeoutMs && <span>Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s</span>}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && (
<DropdownMenuItem onClick={onSetActive}>
<Zap className="w-4 h-4 mr-2" />
Set Active
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -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<string, boolean> {
try {
@@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record<string, boolean>): 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,

View File

@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
const appliedProjectRef = useRef<string | null>(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,
]);
}

View File

@@ -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<GlobalSettings> | 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<GlobalSettings> | 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<string, unknown>).autoModeByWorktree) {
const persistedSettings = (settings as Record<string, unknown>).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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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,

View File

@@ -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<string, { maxConcurrency: number; branchName: string | null }> =
{};
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<boolean> {
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<boolean> {
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<boolean> {
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: {

View File

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

View File

@@ -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<Feature>) =>
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<EnhancePromptResult> =>
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';

View File

@@ -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<Feature>) => 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<AutoModeActivity, 'id' | 'timestamp'>) => 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<void>;
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
deleteClaudeApiProfile: (id: string) => Promise<void>;
setActiveClaudeApiProfile: (id: string | null) => Promise<void>;
setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise<void>;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => 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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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)}`;

View File

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

View File

@@ -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<string, string | undefined> {
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<ActiveClaudeApiProfileResult> {
// 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
<Select
value={formData.apiKeySource}
onValueChange={(value: ApiKeySource) => setFormData({ ...formData, apiKeySource: value })}
>
<SelectContent>
<SelectItem value="credentials">Use saved API key (from Settings API Keys)</SelectItem>
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
<SelectItem value="inline">Enter key for this profile only</SelectItem>
</SelectContent>
</Select>
```
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 |
| **\<Profile Name\>** | 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
- `"<id>"`: 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<ActiveClaudeApiProfileResult>;
```
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

View File

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

View File

@@ -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<string, unknown>;
};
/**
* 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;
}
/**

View File

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

15
package-lock.json generated
View File

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

View File

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

201
start-automaker.mjs Normal file
View File

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

View File

@@ -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<PORT_SEARCH_MAX_ATTEMPTS; i++)); do
if ! is_port_in_use "$port"; then
echo "$port"
return 0
fi
port=$((port + 1))
done
# No free port found in the scan range
return 1
}
kill_port() {
local port=$1
local pids
@@ -460,9 +511,7 @@ kill_port() {
}
check_ports() {
show_cursor
stty echo icanon 2>/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<max_retries; i++)); do
if curl -s "http://$HOSTNAME:$SERVER_PORT/api/health" > /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)