diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index abc5a8674..917672b5a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -37,7 +37,14 @@ jobs: git config --global user.email "ci@example.com" - name: Start backend server - run: npm run start --workspace=apps/server & + run: | + echo "Starting backend server..." + # Start server in background and save PID + npm run start --workspace=apps/server > backend.log 2>&1 & + SERVER_PID=$! + echo "Server started with PID: $SERVER_PID" + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + env: PORT: 3008 NODE_ENV: test @@ -53,21 +60,70 @@ jobs: - name: Wait for backend server run: | echo "Waiting for backend server to be ready..." + + # Check if server process is running + if [ -z "$SERVER_PID" ]; then + echo "ERROR: Server PID not found in environment" + cat backend.log 2>/dev/null || echo "No backend log found" + exit 1 + fi + + # Check if process is actually running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process $SERVER_PID is not running!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Recent system logs ===" + dmesg 2>/dev/null | tail -20 || echo "No dmesg available" + exit 1 + fi + + # Wait for health endpoint for i in {1..60}; do if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then echo "Backend server is ready!" - curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "Health check response:" + curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" exit 0 fi + + # Check if server process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process died during wait!" + echo "=== Backend logs ===" + cat backend.log + exit 1 + fi + echo "Waiting... ($i/60)" sleep 1 done - echo "Backend server failed to start!" - echo "Checking server status..." + + echo "ERROR: Backend server failed to start within 60 seconds!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Process status ===" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" - echo "Testing health endpoint..." + lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use" + echo "" + echo "=== Health endpoint test ===" curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed" + + # Kill the server process if it's still hanging + if kill -0 $SERVER_PID 2>/dev/null; then + echo "" + echo "Killing stuck server process..." + kill -9 $SERVER_PID 2>/dev/null || true + fi + exit 1 - name: Run E2E tests @@ -81,6 +137,18 @@ jobs: # Keep UI-side login/defaults consistent AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + - name: Print backend logs on failure + if: failure() + run: | + echo "=== E2E Tests Failed - Backend Logs ===" + cat backend.log 2>/dev/null || echo "No backend log found" + echo "" + echo "=== Process status at failure ===" + ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" + netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() @@ -98,3 +166,13 @@ jobs: apps/ui/test-results/ retention-days: 7 if-no-files-found: ignore + + - name: Cleanup - Kill backend server + if: always() + run: | + if [ -n "$SERVER_PID" ]; then + echo "Cleaning up backend server (PID: $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null || true + kill -9 $SERVER_PID 2>/dev/null || true + echo "Backend server cleanup complete" + fi diff --git a/.gitignore b/.gitignore index 7d02e8ba2..be8843e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ blob-report/ !.env.example !.env.local.example +# Codex config (contains API keys) +.codex/config.toml + # TypeScript *.tsbuildinfo @@ -84,4 +87,12 @@ docker-compose.override.yml .claude/hans/ pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock + +# Fork-specific workflow files (should never be committed) +DEVELOPMENT_WORKFLOW.md +check-sync.sh +# API key files +data/.api-key +data/credentials.json +data/ diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 755569de8..f763c08d3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -55,6 +55,8 @@ import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; import { createCodexRoutes } from './routes/codex/index.js'; import { CodexUsageService } from './services/codex-usage-service.js'; +import { CodexAppServerService } from './services/codex-app-server-service.js'; +import { CodexModelCacheService } from './services/codex-model-cache-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -168,7 +170,9 @@ const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); const claudeUsageService = new ClaudeUsageService(); -const codexUsageService = new CodexUsageService(); +const codexAppServerService = new CodexAppServerService(); +const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); +const codexUsageService = new CodexUsageService(codexAppServerService); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); @@ -176,6 +180,11 @@ const ideationService = new IdeationService(events, settingsService, featureLoad (async () => { await agentService.initialize(); logger.info('Agent service initialized'); + + // Bootstrap Codex model cache in background (don't block server startup) + void codexModelCacheService.getModels().catch((err) => { + logger.error('Failed to bootstrap Codex model cache:', err); + }); })(); // Run stale validation cleanup every hour to prevent memory leaks from crashed validations @@ -208,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes()); +app.use('/api/worktree', createWorktreeRoutes(events)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); @@ -219,7 +228,7 @@ app.use('/api/templates', createTemplatesRoutes()); app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); -app.use('/api/codex', createCodexRoutes(codexUsageService)); +app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); @@ -588,6 +597,26 @@ const startServer = (port: number) => { startServer(PORT); +// Global error handlers to prevent crashes from uncaught errors +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + logger.error('Unhandled Promise Rejection:', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + // Don't exit - log the error and continue running + // This prevents the server from crashing due to unhandled rejections +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { + message: error.message, + stack: error.stack, + }); + // Exit on uncaught exceptions to prevent undefined behavior + // The process is in an unknown state after an uncaught exception + process.exit(1); +}); + // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down...'); diff --git a/apps/server/src/lib/codex-auth.ts b/apps/server/src/lib/codex-auth.ts index 965885bc0..94fadc8c7 100644 --- a/apps/server/src/lib/codex-auth.ts +++ b/apps/server/src/lib/codex-auth.ts @@ -5,9 +5,11 @@ * Never assumes authenticated - only returns true if CLI confirms. */ -import { spawnProcess, getCodexAuthPath } from '@automaker/platform'; +import { spawnProcess } from '@automaker/platform'; import { findCodexCliPath } from '@automaker/platform'; -import * as fs from 'fs'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('CodexAuth'); const CODEX_COMMAND = 'codex'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; @@ -26,36 +28,16 @@ export interface CodexAuthCheckResult { export async function checkCodexAuthentication( cliPath?: string | null ): Promise { - console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath); - const resolvedCliPath = cliPath || (await findCodexCliPath()); const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; - console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath); - console.log('[CodexAuth] hasApiKey:', hasApiKey); - - // Debug: Check auth file - const authFilePath = getCodexAuthPath(); - console.log('[CodexAuth] Auth file path:', authFilePath); - try { - const authFileExists = fs.existsSync(authFilePath); - console.log('[CodexAuth] Auth file exists:', authFileExists); - if (authFileExists) { - const authContent = fs.readFileSync(authFilePath, 'utf-8'); - console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars - } - } catch (error) { - console.log('[CodexAuth] Error reading auth file:', error); - } - // If CLI is not installed, cannot be authenticated if (!resolvedCliPath) { - console.log('[CodexAuth] No CLI path found, returning not authenticated'); + logger.info('CLI not found'); return { authenticated: false, method: 'none' }; } try { - console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status'); const result = await spawnProcess({ command: resolvedCliPath || CODEX_COMMAND, args: ['login', 'status'], @@ -66,33 +48,21 @@ export async function checkCodexAuthentication( }, }); - console.log('[CodexAuth] Command result:'); - console.log('[CodexAuth] exitCode:', result.exitCode); - console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout)); - console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr)); - // Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr const combinedOutput = (result.stdout + result.stderr).toLowerCase(); const isLoggedIn = combinedOutput.includes('logged in'); - console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn); if (result.exitCode === 0 && isLoggedIn) { // Determine auth method based on what we know const method = hasApiKey ? 'api_key_env' : 'cli_authenticated'; - console.log('[CodexAuth] Authenticated! method:', method); + logger.info(`✓ Authenticated (${method})`); return { authenticated: true, method }; } - console.log( - '[CodexAuth] Not authenticated. exitCode:', - result.exitCode, - 'isLoggedIn:', - isLoggedIn - ); + logger.info('Not authenticated'); + return { authenticated: false, method: 'none' }; } catch (error) { - console.log('[CodexAuth] Error running command:', error); + logger.error('Failed to check authentication:', error); + return { authenticated: false, method: 'none' }; } - - console.log('[CodexAuth] Returning not authenticated'); - return { authenticated: false, method: 'none' }; } diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index edeadc5be..3f7ea60dd 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -21,6 +21,12 @@ export interface WorktreeMetadata { branch: string; createdAt: string; pr?: WorktreePRInfo; + /** Whether the init script has been executed for this worktree */ + initScriptRan?: boolean; + /** Status of the init script execution */ + initScriptStatus?: 'running' | 'success' | 'failed'; + /** Error message if init script failed */ + initScriptError?: string; } /** diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 54e139898..2e3962a0a 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -21,6 +21,7 @@ import { extractTextFromContent, classifyError, getUserFriendlyErrorMessage, + createLogger, } from '@automaker/utils'; import type { ExecuteOptions, @@ -658,6 +659,8 @@ async function loadCodexInstructions(cwd: string, enabled: boolean): Promise { - console.log('[CodexProvider.detectInstallation] Starting...'); - const cliPath = await findCodexCliPath(); const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; const authIndicators = await getCodexAuthIndicators(); const installed = !!cliPath; - console.log('[CodexProvider.detectInstallation] cliPath:', cliPath); - console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey); - console.log( - '[CodexProvider.detectInstallation] authIndicators:', - JSON.stringify(authIndicators) - ); - console.log('[CodexProvider.detectInstallation] installed:', installed); - let version = ''; if (installed) { try { @@ -991,20 +984,16 @@ export class CodexProvider extends BaseProvider { cwd: process.cwd(), }); version = result.stdout.trim(); - console.log('[CodexProvider.detectInstallation] version:', version); } catch (error) { - console.log('[CodexProvider.detectInstallation] Error getting version:', error); version = ''; } } // Determine auth status - always verify with CLI, never assume authenticated - console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...'); const authCheck = await checkCodexAuthentication(cliPath); - console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck)); const authenticated = authCheck.authenticated; - const result = { + return { installed, path: cliPath || undefined, version: version || undefined, @@ -1012,8 +1001,6 @@ export class CodexProvider extends BaseProvider { hasApiKey, authenticated, }; - console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result)); - return result; } getAvailableModels(): ModelDefinition[] { @@ -1025,36 +1012,24 @@ export class CodexProvider extends BaseProvider { * Check authentication status for Codex CLI */ async checkAuth(): Promise { - console.log('[CodexProvider.checkAuth] Starting auth check...'); - const cliPath = await findCodexCliPath(); const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; const authIndicators = await getCodexAuthIndicators(); - console.log('[CodexProvider.checkAuth] cliPath:', cliPath); - console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey); - console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators)); - // Check for API key in environment if (hasApiKey) { - console.log('[CodexProvider.checkAuth] Has API key, returning authenticated'); return { authenticated: true, method: 'api_key' }; } // Check for OAuth/token from Codex CLI if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) { - console.log( - '[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated' - ); return { authenticated: true, method: 'oauth' }; } // CLI is installed but not authenticated via indicators - try CLI command - console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...'); if (cliPath) { try { // Try 'codex login status' first (same as checkCodexAuthentication) - console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status'); const result = await spawnProcess({ command: cliPath || CODEX_COMMAND, args: ['login', 'status'], @@ -1064,26 +1039,19 @@ export class CodexProvider extends BaseProvider { TERM: 'dumb', }, }); - console.log('[CodexProvider.checkAuth] login status result:'); - console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode); - console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout)); - console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr)); // Check both stdout and stderr - Codex CLI outputs to stderr const combinedOutput = (result.stdout + result.stderr).toLowerCase(); const isLoggedIn = combinedOutput.includes('logged in'); - console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn); if (result.exitCode === 0 && isLoggedIn) { - console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated'); return { authenticated: true, method: 'oauth' }; } } catch (error) { - console.log('[CodexProvider.checkAuth] Error running login status:', error); + logger.warn('Error running login status command during auth check:', error); } } - console.log('[CodexProvider.checkAuth] Not authenticated'); return { authenticated: false, method: 'none' }; } diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index b54592c30..a5b3bae2f 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -3,7 +3,7 @@ * * Extends CliProvider with OpenCode-specific configuration: * - Event normalization for OpenCode's stream-json format - * - Model definitions for anthropic, openai, and google models + * - Dynamic model discovery via `opencode models` CLI command * - NPX-based Windows execution strategy * - Platform-specific npm global installation paths * @@ -12,7 +12,11 @@ import * as path from 'path'; import * as os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { CliProvider, type CliSpawnConfig } from './cli-provider.js'; + +const execFileAsync = promisify(execFile); import type { ProviderConfig, ExecuteOptions, @@ -23,6 +27,10 @@ import type { } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +// Create logger for OpenCode operations +const opencodeLogger = createLogger('OpencodeProvider'); // ============================================================================= // OpenCode Auth Types @@ -35,106 +43,167 @@ export interface OpenCodeAuthStatus { hasApiKey?: boolean; } +// ============================================================================= +// OpenCode Dynamic Model Types +// ============================================================================= + +/** + * Model information from `opencode models` CLI output + */ +export interface OpenCodeModelInfo { + /** Full model ID (e.g., "copilot/claude-sonnet-4-5") */ + id: string; + /** Provider name (e.g., "copilot", "anthropic", "openai") */ + provider: string; + /** Model name without provider prefix */ + name: string; + /** Display name for UI */ + displayName?: string; +} + +/** + * Provider information from `opencode auth list` CLI output + */ +export interface OpenCodeProviderInfo { + /** Provider ID (e.g., "copilot", "anthropic") */ + id: string; + /** Human-readable name */ + name: string; + /** Whether the provider is authenticated */ + authenticated: boolean; + /** Authentication method if authenticated */ + authMethod?: 'oauth' | 'api_key'; +} + +/** Cache duration for dynamic model fetching (5 minutes) */ +const MODEL_CACHE_DURATION_MS = 5 * 60 * 1000; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_MODEL_ID_PATTERN = /^[a-z0-9.-]+\/\S+$/; +const OPENCODE_PROVIDER_PATTERN = /^[a-z0-9.-]+$/; +const OPENCODE_MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/; + // ============================================================================= // OpenCode Stream Event Types // ============================================================================= +/** + * Part object within OpenCode events + */ +interface OpenCodePart { + id?: string; + sessionID?: string; + messageID?: string; + type: string; + text?: string; + reason?: string; + error?: string; + name?: string; + args?: unknown; + call_id?: string; + output?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + }; +} + /** * Base interface for all OpenCode stream events + * Format: {"type":"event_type","timestamp":...,"sessionID":"...","part":{...}} */ interface OpenCodeBaseEvent { - /** Event type identifier */ + /** Event type identifier (step_start, text, step_finish, tool_call, etc.) */ type: string; - /** Optional session identifier */ - session_id?: string; + /** Unix timestamp */ + timestamp?: number; + /** Session identifier */ + sessionID?: string; + /** Event details */ + part?: OpenCodePart; } /** - * Text delta event - Incremental text output from the model + * Text event - Text output from the model */ -export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent { - type: 'text-delta'; - /** The incremental text content */ - text: string; +export interface OpenCodeTextEvent extends OpenCodeBaseEvent { + type: 'text'; + part: OpenCodePart & { type: 'text'; text: string }; } /** - * Text end event - Signals completion of text generation + * Step start event - Begins an agentic loop iteration */ -export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent { - type: 'text-end'; +export interface OpenCodeStepStartEvent extends OpenCodeBaseEvent { + type: 'step_start'; + part: OpenCodePart & { type: 'step-start' }; +} + +/** + * Step finish event - Completes an agentic loop iteration + */ +export interface OpenCodeStepFinishEvent extends OpenCodeBaseEvent { + type: 'step_finish'; + part: OpenCodePart & { type: 'step-finish'; reason?: string }; } /** * Tool call event - Request to execute a tool */ export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { - type: 'tool-call'; - /** Unique identifier for this tool call */ - call_id?: string; - /** Tool name to invoke */ - name: string; - /** Arguments to pass to the tool */ - args: unknown; + type: 'tool_call'; + part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown }; } /** * Tool result event - Output from a tool execution */ export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { - type: 'tool-result'; - /** The tool call ID this result corresponds to */ - call_id?: string; - /** Output from the tool execution */ - output: string; + type: 'tool_result'; + part: OpenCodePart & { type: 'tool-result'; output: string }; } /** - * Tool error event - Tool execution failed + * Error details object in error events */ -export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { - type: 'tool-error'; - /** The tool call ID that failed */ - call_id?: string; - /** Error message describing the failure */ - error: string; +interface OpenCodeErrorDetails { + name?: string; + message?: string; + data?: { + message?: string; + statusCode?: number; + isRetryable?: boolean; + }; } /** - * Start step event - Begins an agentic loop iteration + * Error event - An error occurred */ -export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent { - type: 'start-step'; - /** Step number in the agentic loop */ - step?: number; +export interface OpenCodeErrorEvent extends OpenCodeBaseEvent { + type: 'error'; + part?: OpenCodePart & { error: string }; + error?: string | OpenCodeErrorDetails; } /** - * Finish step event - Completes an agentic loop iteration + * Tool error event - A tool execution failed */ -export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent { - type: 'finish-step'; - /** Step number that completed */ - step?: number; - /** Whether the step completed successfully */ - success?: boolean; - /** Optional result data */ - result?: string; - /** Optional error if step failed */ - error?: string; +export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { + type: 'tool_error'; + part?: OpenCodePart & { error: string }; } /** * Union type of all OpenCode stream events */ export type OpenCodeStreamEvent = - | OpenCodeTextDeltaEvent - | OpenCodeTextEndEvent + | OpenCodeTextEvent + | OpenCodeStepStartEvent + | OpenCodeStepFinishEvent | OpenCodeToolCallEvent | OpenCodeToolResultEvent - | OpenCodeToolErrorEvent - | OpenCodeStartStepEvent - | OpenCodeFinishStepEvent; + | OpenCodeErrorEvent + | OpenCodeToolErrorEvent; // ============================================================================= // Tool Use ID Generation @@ -167,8 +236,31 @@ export function resetToolUseIdCounter(): void { * * OpenCode is an npm-distributed CLI tool that provides access to * multiple AI model providers through a unified interface. + * + * Supports dynamic model discovery via `opencode models` CLI command, + * enabling access to 75+ providers including GitHub Copilot, Google, + * Anthropic, OpenAI, and more based on user authentication. */ export class OpencodeProvider extends CliProvider { + // ========================================================================== + // Dynamic Model Cache + // ========================================================================== + + /** Cached model definitions */ + private cachedModels: ModelDefinition[] | null = null; + + /** Timestamp when cache expires */ + private modelsCacheExpiry: number = 0; + + /** Cached authenticated providers */ + private cachedProviders: OpenCodeProviderInfo[] | null = null; + + /** Whether model refresh is in progress */ + private isRefreshing: boolean = false; + + /** Promise that resolves when current refresh completes */ + private refreshPromise: Promise | null = null; + constructor(config: ProviderConfig = {}) { super(config); } @@ -219,14 +311,12 @@ export class OpencodeProvider extends CliProvider { * * Arguments built: * - 'run' subcommand for executing queries - * - '--format', 'stream-json' for JSONL streaming output - * - '-q' / '--quiet' to suppress spinner and interactive elements - * - '-c', '' for working directory + * - '--format', 'json' for JSONL streaming output + * - '-c', '' for working directory (using opencode's -c flag) * - '--model', '' for model selection (if specified) - * - '-' as final arg to read prompt from stdin * - * The prompt is NOT included in CLI args - it's passed via stdin to avoid - * shell escaping issues with special characters in content. + * The prompt is passed via stdin (piped) to avoid shell escaping issues. + * OpenCode CLI automatically reads from stdin when input is piped. * * @param options - Execution options containing model, cwd, etc. * @returns Array of CLI arguments for opencode run @@ -234,16 +324,8 @@ export class OpencodeProvider extends CliProvider { buildCliArgs(options: ExecuteOptions): string[] { const args: string[] = ['run']; - // Add streaming JSON output format for JSONL parsing - args.push('--format', 'stream-json'); - - // Suppress spinner and interactive elements for non-TTY usage - args.push('-q'); - - // Set working directory - if (options.cwd) { - args.push('-c', options.cwd); - } + // Add JSON output format for JSONL parsing (not 'stream-json') + args.push('--format', 'json'); // Handle model selection // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' @@ -252,9 +334,8 @@ export class OpencodeProvider extends CliProvider { args.push('--model', model); } - // Use '-' to indicate reading prompt from stdin - // This avoids shell escaping issues with special characters - args.push('-'); + // Note: OpenCode reads from stdin automatically when input is piped + // No '-' argument needed return args; } @@ -314,14 +395,13 @@ export class OpencodeProvider extends CliProvider { * Normalize a raw CLI event to ProviderMessage format * * Maps OpenCode event types to the standard ProviderMessage structure: - * - text-delta -> type: 'assistant', content with type: 'text' - * - text-end -> null (informational, no message needed) - * - tool-call -> type: 'assistant', content with type: 'tool_use' - * - tool-result -> type: 'assistant', content with type: 'tool_result' - * - tool-error -> type: 'error' - * - start-step -> null (informational, no message needed) - * - finish-step with success -> type: 'result', subtype: 'success' - * - finish-step with error -> type: 'error' + * - text -> type: 'assistant', content with type: 'text' + * - step_start -> null (informational, no message needed) + * - step_finish with reason 'stop' -> type: 'result', subtype: 'success' + * - step_finish with error -> type: 'error' + * - tool_call -> type: 'assistant', content with type: 'tool_use' + * - tool_result -> type: 'assistant', content with type: 'tool_result' + * - error -> type: 'error' * * @param event - Raw event from OpenCode CLI JSONL output * @returns Normalized ProviderMessage or null to skip the event @@ -334,24 +414,24 @@ export class OpencodeProvider extends CliProvider { const openCodeEvent = event as OpenCodeStreamEvent; switch (openCodeEvent.type) { - case 'text-delta': { - const textEvent = openCodeEvent as OpenCodeTextDeltaEvent; + case 'text': { + const textEvent = openCodeEvent as OpenCodeTextEvent; - // Skip empty text deltas - if (!textEvent.text) { + // Skip empty text + if (!textEvent.part?.text) { return null; } const content: ContentBlock[] = [ { type: 'text', - text: textEvent.text, + text: textEvent.part.text, }, ]; return { type: 'assistant', - session_id: textEvent.session_id, + session_id: textEvent.sessionID, message: { role: 'assistant', content, @@ -359,29 +439,72 @@ export class OpencodeProvider extends CliProvider { }; } - case 'text-end': { - // Text end is informational - no message needed + case 'step_start': { + // Step start is informational - no message needed return null; } - case 'tool-call': { + case 'step_finish': { + const finishEvent = openCodeEvent as OpenCodeStepFinishEvent; + + // Check if the step failed - either by error property or reason='error' + if (finishEvent.part?.error) { + return { + type: 'error', + session_id: finishEvent.sessionID, + error: finishEvent.part.error, + }; + } + + // Check if reason indicates error (even without explicit error text) + if (finishEvent.part?.reason === 'error') { + return { + type: 'error', + session_id: finishEvent.sessionID, + error: 'Step execution failed', + }; + } + + // Successful completion (reason: 'stop' or 'end_turn') + return { + type: 'result', + subtype: 'success', + session_id: finishEvent.sessionID, + result: (finishEvent.part as OpenCodePart & { result?: string })?.result, + }; + } + + case 'tool_error': { + const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent; + + // Extract error message from part.error + const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed'; + + return { + type: 'error', + session_id: toolErrorEvent.sessionID, + error: errorMessage, + }; + } + + case 'tool_call': { const toolEvent = openCodeEvent as OpenCodeToolCallEvent; // Generate a tool use ID if not provided - const toolUseId = toolEvent.call_id || generateToolUseId(); + const toolUseId = toolEvent.part?.call_id || generateToolUseId(); const content: ContentBlock[] = [ { type: 'tool_use', - name: toolEvent.name, + name: toolEvent.part?.name || 'unknown', tool_use_id: toolUseId, - input: toolEvent.args, + input: toolEvent.part?.args, }, ]; return { type: 'assistant', - session_id: toolEvent.session_id, + session_id: toolEvent.sessionID, message: { role: 'assistant', content, @@ -389,20 +512,20 @@ export class OpencodeProvider extends CliProvider { }; } - case 'tool-result': { + case 'tool_result': { const resultEvent = openCodeEvent as OpenCodeToolResultEvent; const content: ContentBlock[] = [ { type: 'tool_result', - tool_use_id: resultEvent.call_id, - content: resultEvent.output, + tool_use_id: resultEvent.part?.call_id, + content: resultEvent.part?.output || '', }, ]; return { type: 'assistant', - session_id: resultEvent.session_id, + session_id: resultEvent.sessionID, message: { role: 'assistant', content, @@ -410,39 +533,30 @@ export class OpencodeProvider extends CliProvider { }; } - case 'tool-error': { - const errorEvent = openCodeEvent as OpenCodeToolErrorEvent; - - return { - type: 'error', - session_id: errorEvent.session_id, - error: errorEvent.error || 'Tool execution failed', - }; - } - - case 'start-step': { - // Start step is informational - no message needed - return null; - } - - case 'finish-step': { - const finishEvent = openCodeEvent as OpenCodeFinishStepEvent; - - // Check if the step failed - if (finishEvent.success === false || finishEvent.error) { - return { - type: 'error', - session_id: finishEvent.session_id, - error: finishEvent.error || 'Step execution failed', - }; + case 'error': { + const errorEvent = openCodeEvent as OpenCodeErrorEvent; + + // Extract error message from various formats + let errorMessage = 'Unknown error'; + if (errorEvent.error) { + if (typeof errorEvent.error === 'string') { + errorMessage = errorEvent.error; + } else { + // Error is an object with name/data structure + errorMessage = + errorEvent.error.data?.message || + errorEvent.error.message || + errorEvent.error.name || + 'Unknown error'; + } + } else if (errorEvent.part?.error) { + errorMessage = errorEvent.part.error; } - // Successful completion return { - type: 'result', - subtype: 'success', - session_id: finishEvent.session_id, - result: finishEvent.result, + type: 'error', + session_id: errorEvent.sessionID, + error: errorMessage, }; } @@ -460,12 +574,34 @@ export class OpencodeProvider extends CliProvider { /** * Get available models for OpenCode * - * Returns model definitions for supported AI providers: - * - Anthropic Claude models (Sonnet, Opus, Haiku) - * - OpenAI GPT-4o - * - Google Gemini 2.5 Pro + * Returns cached models if available and not expired. + * Falls back to default models if cache is empty or CLI is unavailable. + * + * Use `refreshModels()` to force a fresh fetch from the CLI. */ getAvailableModels(): ModelDefinition[] { + // Return cached models if available and not expired + if (this.cachedModels && Date.now() < this.modelsCacheExpiry) { + return this.cachedModels; + } + + // Return cached models even if expired (better than nothing) + if (this.cachedModels) { + // Trigger background refresh + this.refreshModels().catch((err) => { + opencodeLogger.debug(`Background model refresh failed: ${err}`); + }); + return this.cachedModels; + } + + // Return default models while cache is empty + return this.getDefaultModels(); + } + + /** + * Get default hardcoded models (fallback when CLI is unavailable) + */ + private getDefaultModels(): ModelDefinition[] { return [ // OpenCode Free Tier Models { @@ -477,6 +613,17 @@ export class OpencodeProvider extends CliProvider { supportsTools: true, supportsVision: false, tier: 'basic', + default: true, + }, + { + id: 'opencode/glm-4.7-free', + name: 'GLM 4.7 Free', + modelString: 'opencode/glm-4.7-free', + provider: 'opencode', + description: 'OpenCode free tier GLM model', + supportsTools: true, + supportsVision: false, + tier: 'basic', }, { id: 'opencode/gpt-5-nano', @@ -498,85 +645,466 @@ export class OpencodeProvider extends CliProvider { supportsVision: false, tier: 'basic', }, - // Amazon Bedrock - Claude Models - { - id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - name: 'Claude Sonnet 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - provider: 'opencode', - description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent', - supportsTools: true, - supportsVision: true, - tier: 'premium', - default: true, - }, - { - id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - name: 'Claude Opus 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - provider: 'opencode', - description: 'Most capable Claude model via AWS Bedrock', - supportsTools: true, - supportsVision: true, - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - name: 'Claude Haiku 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - provider: 'opencode', - description: 'Fastest Claude model via AWS Bedrock', - supportsTools: true, - supportsVision: true, - tier: 'standard', - }, - // Amazon Bedrock - DeepSeek Models - { - id: 'amazon-bedrock/deepseek.r1-v1:0', - name: 'DeepSeek R1 (Bedrock)', - modelString: 'amazon-bedrock/deepseek.r1-v1:0', - provider: 'opencode', - description: 'DeepSeek R1 reasoning model - excellent for coding', - supportsTools: true, - supportsVision: false, - tier: 'premium', - }, - // Amazon Bedrock - Amazon Nova Models - { - id: 'amazon-bedrock/amazon.nova-pro-v1:0', - name: 'Amazon Nova Pro (Bedrock)', - modelString: 'amazon-bedrock/amazon.nova-pro-v1:0', - provider: 'opencode', - description: 'Amazon Nova Pro - balanced performance', - supportsTools: true, - supportsVision: true, - tier: 'standard', - }, - // Amazon Bedrock - Meta Llama Models - { - id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - name: 'Llama 4 Maverick 17B (Bedrock)', - modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - provider: 'opencode', - description: 'Meta Llama 4 Maverick via AWS Bedrock', - supportsTools: true, - supportsVision: false, - tier: 'standard', - }, - // Amazon Bedrock - Qwen Models { - id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', - name: 'Qwen3 Coder 480B (Bedrock)', - modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', + id: 'opencode/minimax-m2.1-free', + name: 'MiniMax M2.1 Free', + modelString: 'opencode/minimax-m2.1-free', provider: 'opencode', - description: 'Qwen3 Coder 480B - excellent for coding', + description: 'OpenCode free tier MiniMax model', supportsTools: true, supportsVision: false, - tier: 'premium', + tier: 'basic', }, ]; } + // ========================================================================== + // Dynamic Model Discovery + // ========================================================================== + + /** + * Refresh models from OpenCode CLI + * + * Fetches available models using `opencode models` command and updates cache. + * Returns the updated model definitions. + */ + async refreshModels(): Promise { + // If refresh is in progress, wait for existing promise instead of busy-waiting + if (this.isRefreshing && this.refreshPromise) { + opencodeLogger.debug('Model refresh already in progress, waiting for completion...'); + return this.refreshPromise; + } + + this.isRefreshing = true; + opencodeLogger.debug('Starting model refresh from OpenCode CLI'); + + this.refreshPromise = this.doRefreshModels(); + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + this.isRefreshing = false; + } + } + + /** + * Internal method that performs the actual model refresh + */ + private async doRefreshModels(): Promise { + try { + const models = await this.fetchModelsFromCli(); + + if (models.length > 0) { + this.cachedModels = models; + this.modelsCacheExpiry = Date.now() + MODEL_CACHE_DURATION_MS; + opencodeLogger.debug(`Cached ${models.length} models from OpenCode CLI`); + } else { + // Keep existing cache if fetch returned nothing + opencodeLogger.debug('No models returned from CLI, keeping existing cache'); + } + + return this.cachedModels || this.getDefaultModels(); + } catch (error) { + opencodeLogger.debug(`Model refresh failed: ${error}`); + // Return existing cache or defaults on error + return this.cachedModels || this.getDefaultModels(); + } + } + + /** + * Fetch models from OpenCode CLI using `opencode models` command + * + * Uses async execFile to avoid blocking the event loop. + */ + private async fetchModelsFromCli(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for model fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy: execute npx with opencode-ai package + command = 'npx'; + args = ['opencode-ai@latest', 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy: execute via wsl.exe + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'models'] + : [this.wslCliPath, 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 30000, + windowsHide: true, + }); + + opencodeLogger.debug( + `Models output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + return this.parseModelsOutput(stdout); + } catch (error) { + opencodeLogger.error(`Failed to fetch models from CLI: ${error}`); + return []; + } + } + + /** + * Parse the output of `opencode models` command + * + * OpenCode CLI output format (one model per line): + * opencode/big-pickle + * opencode/glm-4.7-free + * anthropic/claude-3-5-haiku-20241022 + * github-copilot/claude-3.5-sonnet + * ... + */ + private parseModelsOutput(output: string): ModelDefinition[] { + // Parse line-based format (one model ID per line) + const lines = output.split('\n'); + const models: ModelDefinition[] = []; + + // Regex to validate "provider/model-name" format + // Provider: lowercase letters, numbers, dots, hyphens + // Model name: non-whitespace (supports nested paths like openrouter/anthropic/claude) + const modelIdRegex = OPENCODE_MODEL_ID_PATTERN; + + for (const line of lines) { + // Remove ANSI escape codes if any + const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + + // Skip empty lines + if (!cleanLine) continue; + + // Validate format using regex for robustness + if (modelIdRegex.test(cleanLine)) { + const separatorIndex = cleanLine.indexOf(OPENCODE_MODEL_ID_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === cleanLine.length - 1) { + continue; + } + + const provider = cleanLine.slice(0, separatorIndex); + const name = cleanLine.slice(separatorIndex + 1); + + if (!OPENCODE_PROVIDER_PATTERN.test(provider) || !OPENCODE_MODEL_NAME_PATTERN.test(name)) { + continue; + } + + models.push( + this.modelInfoToDefinition({ + id: cleanLine, + provider, + name, + }) + ); + } + } + + opencodeLogger.debug(`Parsed ${models.length} models from CLI output`); + return models; + } + + /** + * Convert OpenCodeModelInfo to ModelDefinition + */ + private modelInfoToDefinition(model: OpenCodeModelInfo): ModelDefinition { + const displayName = model.displayName || this.formatModelDisplayName(model); + const tier = this.inferModelTier(model.id); + + return { + id: model.id, + name: displayName, + modelString: model.id, + provider: model.provider, // Use the actual provider (github-copilot, google, etc.) + description: `${model.name} via ${this.formatProviderName(model.provider)}`, + supportsTools: true, + supportsVision: this.modelSupportsVision(model.id), + tier, + // Mark Claude Sonnet as default if available + default: model.id.includes('claude-sonnet-4'), + }; + } + + /** + * Format provider name for display + */ + private formatProviderName(provider: string): string { + const providerNames: Record = { + 'github-copilot': 'GitHub Copilot', + google: 'Google AI', + openai: 'OpenAI', + anthropic: 'Anthropic', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + ollama: 'Ollama', + lmstudio: 'LM Studio', + azure: 'Azure OpenAI', + xai: 'xAI', + deepseek: 'DeepSeek', + }; + return ( + providerNames[provider] || + provider.charAt(0).toUpperCase() + provider.slice(1).replace(/-/g, ' ') + ); + } + + /** + * Format a display name for a model + */ + private formatModelDisplayName(model: OpenCodeModelInfo): string { + // Capitalize and format the model name + const formattedName = model.name + .split('-') + .map((part) => { + // Handle version numbers like "4-5" -> "4.5" + if (/^\d+$/.test(part)) { + return part; + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(' ') + .replace(/(\d)\s+(\d)/g, '$1.$2'); // "4 5" -> "4.5" + + // Format provider name + const providerNames: Record = { + copilot: 'GitHub Copilot', + anthropic: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + 'amazon-bedrock': 'AWS Bedrock', + bedrock: 'AWS Bedrock', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + azure: 'Azure', + ollama: 'Ollama', + lmstudio: 'LM Studio', + }; + + const providerDisplay = providerNames[model.provider] || model.provider; + return `${formattedName} (${providerDisplay})`; + } + + /** + * Infer model tier based on model ID + */ + private inferModelTier(modelId: string): 'basic' | 'standard' | 'premium' { + const lowerModelId = modelId.toLowerCase(); + + // Premium tier: flagship models + if ( + lowerModelId.includes('opus') || + lowerModelId.includes('gpt-5') || + lowerModelId.includes('o3') || + lowerModelId.includes('o4') || + lowerModelId.includes('gemini-2') || + lowerModelId.includes('deepseek-r1') + ) { + return 'premium'; + } + + // Basic tier: free or lightweight models + if ( + lowerModelId.includes('free') || + lowerModelId.includes('nano') || + lowerModelId.includes('mini') || + lowerModelId.includes('haiku') || + lowerModelId.includes('flash') + ) { + return 'basic'; + } + + // Standard tier: everything else + return 'standard'; + } + + /** + * Check if a model supports vision based on model ID + */ + private modelSupportsVision(modelId: string): boolean { + const lowerModelId = modelId.toLowerCase(); + + // Models known to support vision + const visionModels = ['claude', 'gpt-4', 'gpt-5', 'gemini', 'nova', 'llama-3', 'llama-4']; + + return visionModels.some((vm) => lowerModelId.includes(vm)); + } + + /** + * Fetch authenticated providers from OpenCode CLI + * + * Runs `opencode auth list` to get the list of authenticated providers. + * Uses async execFile to avoid blocking the event loop. + */ + async fetchAuthenticatedProviders(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for provider fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy + command = 'npx'; + args = ['opencode-ai@latest', 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'auth', 'list'] + : [this.wslCliPath, 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 15000, + windowsHide: true, + }); + + opencodeLogger.debug( + `Auth list output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + const providers = this.parseProvidersOutput(stdout); + this.cachedProviders = providers; + return providers; + } catch (error) { + opencodeLogger.error(`Failed to fetch providers from CLI: ${error}`); + return this.cachedProviders || []; + } + } + + /** + * Parse the output of `opencode auth list` command + * + * OpenCode CLI output format: + * ┌ Credentials ~/.local/share/opencode/auth.json + * │ + * ● Anthropic oauth + * │ + * ● GitHub Copilot oauth + * │ + * └ 4 credentials + * + * Each line with ● contains: provider name and auth method (oauth/api) + */ + private parseProvidersOutput(output: string): OpenCodeProviderInfo[] { + const lines = output.split('\n'); + const providers: OpenCodeProviderInfo[] = []; + + // Provider name to ID mapping + const providerIdMap: Record = { + anthropic: 'anthropic', + 'github copilot': 'github-copilot', + copilot: 'github-copilot', + google: 'google', + openai: 'openai', + openrouter: 'openrouter', + azure: 'azure', + bedrock: 'amazon-bedrock', + 'amazon bedrock': 'amazon-bedrock', + ollama: 'ollama', + 'lm studio': 'lmstudio', + lmstudio: 'lmstudio', + opencode: 'opencode', + 'z.ai coding plan': 'z-ai', + 'z.ai': 'z-ai', + }; + + for (const line of lines) { + // Look for lines with ● which indicate authenticated providers + // Format: "● Provider Name auth_method" + if (line.includes('●')) { + // Remove ANSI escape codes and the ● symbol + const cleanLine = line + .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI codes + .replace(/●/g, '') // Remove ● symbol + .trim(); + + if (!cleanLine) continue; + + // Parse "Provider Name auth_method" format + // Auth method is the last word (oauth, api, etc.) + const parts = cleanLine.split(/\s+/); + if (parts.length >= 2) { + const authMethod = parts[parts.length - 1].toLowerCase(); + const providerName = parts.slice(0, -1).join(' '); + + // Determine auth method type + let authMethodType: 'oauth' | 'api_key' | undefined; + if (authMethod === 'oauth') { + authMethodType = 'oauth'; + } else if (authMethod === 'api' || authMethod === 'api_key') { + authMethodType = 'api_key'; + } + + // Get provider ID from name + const providerNameLower = providerName.toLowerCase(); + const providerId = + providerIdMap[providerNameLower] || providerNameLower.replace(/\s+/g, '-'); + + providers.push({ + id: providerId, + name: providerName, + authenticated: true, // If it's listed with ●, it's authenticated + authMethod: authMethodType, + }); + } + } + } + + opencodeLogger.debug(`Parsed ${providers.length} providers from auth list`); + return providers; + } + + /** + * Get cached authenticated providers + */ + getCachedProviders(): OpenCodeProviderInfo[] | null { + return this.cachedProviders; + } + + /** + * Clear the model cache, forcing a refresh on next access + */ + clearModelCache(): void { + this.cachedModels = null; + this.modelsCacheExpiry = 0; + this.cachedProviders = null; + opencodeLogger.debug('Model cache cleared'); + } + + /** + * Check if we have cached models (not just defaults) + */ + hasCachedModels(): boolean { + return this.cachedModels !== null && this.cachedModels.length > 0; + } + // ========================================================================== // Feature Support // ========================================================================== diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index df412dc67..7ef1aabeb 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('SpecRegeneration'); -// Shared state for tracking generation status - private -let isRunning = false; -let currentAbortController: AbortController | null = null; +// Shared state for tracking generation status - scoped by project path +const runningProjects = new Map(); +const abortControllers = new Map(); /** - * Get the current running state + * Get the running state for a specific project */ -export function getSpecRegenerationStatus(): { +export function getSpecRegenerationStatus(projectPath?: string): { isRunning: boolean; currentAbortController: AbortController | null; + projectPath?: string; } { - return { isRunning, currentAbortController }; + if (projectPath) { + return { + isRunning: runningProjects.get(projectPath) || false, + currentAbortController: abortControllers.get(projectPath) || null, + projectPath, + }; + } + // Fallback: check if any project is running (for backward compatibility) + const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); + return { isRunning: isAnyRunning, currentAbortController: null }; } /** - * Set the running state and abort controller + * Get the project path that is currently running (if any) */ -export function setRunningState(running: boolean, controller: AbortController | null = null): void { - isRunning = running; - currentAbortController = controller; +export function getRunningProjectPath(): string | null { + for (const [path, running] of runningProjects.entries()) { + if (running) return path; + } + return null; +} + +/** + * Set the running state and abort controller for a specific project + */ +export function setRunningState( + projectPath: string, + running: boolean, + controller: AbortController | null = null +): void { + if (running) { + runningProjects.set(projectPath, true); + if (controller) { + abortControllers.set(projectPath, controller); + } + } else { + runningProjects.delete(projectPath); + abortControllers.delete(projectPath); + } } /** diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index ed6f68f11..31836867e 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) { return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); // Start generation in background @@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) { }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index 0c80a9b62..dc627964c 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler( return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Generation already running for this project' }); return; } logAuthStatus('Before starting feature generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background feature generation task...'); generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) @@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler( }) .finally(() => { logger.info('Feature generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate.ts b/apps/server/src/routes/app-spec/routes/generate.ts index a03dacb7e..ffc792aea 100644 --- a/apps/server/src/routes/app-spec/routes/generate.ts +++ b/apps/server/src/routes/app-spec/routes/generate.ts @@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); generateSpec( @@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/status.ts b/apps/server/src/routes/app-spec/routes/status.ts index 542dd4f30..34caea326 100644 --- a/apps/server/src/routes/app-spec/routes/status.ts +++ b/apps/server/src/routes/app-spec/routes/status.ts @@ -6,10 +6,11 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; export function createStatusHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { isRunning } = getSpecRegenerationStatus(); - res.json({ success: true, isRunning }); + const projectPath = req.query.projectPath as string | undefined; + const { isRunning } = getSpecRegenerationStatus(projectPath); + res.json({ success: true, isRunning, projectPath }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/app-spec/routes/stop.ts b/apps/server/src/routes/app-spec/routes/stop.ts index 0751147b9..2a7b0aab3 100644 --- a/apps/server/src/routes/app-spec/routes/stop.ts +++ b/apps/server/src/routes/app-spec/routes/stop.ts @@ -6,13 +6,16 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; export function createStopHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { currentAbortController } = getSpecRegenerationStatus(); + const { projectPath } = req.body as { projectPath?: string }; + const { currentAbortController } = getSpecRegenerationStatus(projectPath); if (currentAbortController) { currentAbortController.abort(); } - setRunningState(false, null); + if (projectPath) { + setRunningState(projectPath, false, null); + } res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 5f36d691a..16dbd1972 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js'; import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createApprovePlanHandler } from './routes/approve-plan.js'; +import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); @@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router { validatePathParams('projectPath'), createApprovePlanHandler(autoModeService) ); + router.post( + '/resume-interrupted', + validatePathParams('projectPath'), + createResumeInterruptedHandler(autoModeService) + ); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts new file mode 100644 index 000000000..36cda2bd9 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -0,0 +1,42 @@ +/** + * Resume Interrupted Features Handler + * + * Checks for features that were interrupted (in pipeline steps or in_progress) + * when the server was restarted and resumes them. + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; + +const logger = createLogger('ResumeInterrupted'); + +interface ResumeInterruptedRequest { + projectPath: string; +} + +export function createResumeInterruptedHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ResumeInterruptedRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Checking for interrupted features in ${projectPath}`); + + try { + await autoModeService.resumeInterruptedFeatures(projectPath); + res.json({ + success: true, + message: 'Resume check completed', + }); + } catch (error) { + logger.error('Error resuming interrupted features:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 71dc3bd95..b6c257a05 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader(); export function createApplyHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, plan } = req.body as { + const { + projectPath, + plan, + branchName: rawBranchName, + } = req.body as { projectPath: string; plan: BacklogPlanResult; + branchName?: string; }; + // Validate branchName: must be undefined or a non-empty trimmed string + const branchName = + typeof rawBranchName === 'string' && rawBranchName.trim().length > 0 + ? rawBranchName.trim() + : undefined; + if (!projectPath) { res.status(400).json({ success: false, error: 'projectPath required' }); return; @@ -82,6 +93,7 @@ export function createApplyHandler() { dependencies: change.feature.dependencies, priority: change.feature.priority, status: 'backlog', + branchName, }); appliedChanges.push(`added:${newFeature.id}`); diff --git a/apps/server/src/routes/codex/index.ts b/apps/server/src/routes/codex/index.ts index 4a2db951b..005a81bc4 100644 --- a/apps/server/src/routes/codex/index.ts +++ b/apps/server/src/routes/codex/index.ts @@ -1,17 +1,21 @@ import { Router, Request, Response } from 'express'; import { CodexUsageService } from '../../services/codex-usage-service.js'; +import { CodexModelCacheService } from '../../services/codex-model-cache-service.js'; import { createLogger } from '@automaker/utils'; const logger = createLogger('Codex'); -export function createCodexRoutes(service: CodexUsageService): Router { +export function createCodexRoutes( + usageService: CodexUsageService, + modelCacheService: CodexModelCacheService +): Router { const router = Router(); // Get current usage (attempts to fetch from Codex CLI) - router.get('/usage', async (req: Request, res: Response) => { + router.get('/usage', async (_req: Request, res: Response) => { try { // Check if Codex CLI is available first - const isAvailable = await service.isAvailable(); + const isAvailable = await usageService.isAvailable(); if (!isAvailable) { // IMPORTANT: This endpoint is behind Automaker session auth already. // Use a 200 + error payload for Codex CLI issues so the UI doesn't @@ -23,7 +27,7 @@ export function createCodexRoutes(service: CodexUsageService): Router { return; } - const usage = await service.fetchUsageData(); + const usage = await usageService.fetchUsageData(); res.json(usage); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -52,5 +56,35 @@ export function createCodexRoutes(service: CodexUsageService): Router { } }); + // Get available Codex models (cached) + router.get('/models', async (req: Request, res: Response) => { + try { + const forceRefresh = req.query.refresh === 'true'; + const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh); + + if (models.length === 0) { + res.status(503).json({ + success: false, + error: 'Codex CLI not available or not authenticated', + message: "Please install Codex CLI and run 'codex login' to authenticate", + }); + return; + } + + res.json({ + success: true, + models, + cachedAt, + }); + } catch (error) { + logger.error('Error fetching models:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ + success: false, + error: message, + }); + } + }); + return router; } diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 4c3a9da4d..73043284d 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, isCursorModel, + isOpencodeModel, stripProviderPrefix, ThinkingLevel, getThinkingTokenBudget, @@ -91,13 +92,13 @@ async function extractTextFromStream( } /** - * Execute enhancement using Cursor provider + * Execute enhancement using a provider (Cursor, OpenCode, etc.) * * @param prompt - The enhancement prompt - * @param model - The Cursor model to use + * @param model - The model to use * @returns The enhanced text */ -async function executeWithCursor(prompt: string, model: string): Promise { +async function executeWithProvider(prompt: string, model: string): Promise { const provider = ProviderFactory.getProviderForModel(model); // Strip provider prefix - providers expect bare model IDs const bareModel = stripProviderPrefix(model); @@ -110,7 +111,11 @@ async function executeWithCursor(prompt: string, model: string): Promise cwd: process.cwd(), // Enhancement doesn't need a specific working directory readOnly: true, // Prompt enhancement only generates text, doesn't write files })) { - if (msg.type === 'assistant' && msg.message?.content) { + if (msg.type === 'error') { + // Throw error with the message from the provider + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } else if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text' && block.text) { responseText += block.text; @@ -188,6 +193,7 @@ export function createEnhanceHandler( technical: prompts.enhancement.technicalSystemPrompt, simplify: prompts.enhancement.simplifySystemPrompt, acceptance: prompts.enhancement.acceptanceSystemPrompt, + 'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt, }; const systemPrompt = systemPromptMap[validMode]; @@ -211,7 +217,14 @@ export function createEnhanceHandler( // Cursor doesn't have a separate system prompt concept, so combine them const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; - enhancedText = await executeWithCursor(combinedPrompt, resolvedModel); + enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); + } else if (isOpencodeModel(resolvedModel)) { + // Use OpenCode provider for OpenCode models (static and dynamic) + logger.info(`Using OpenCode provider for model: ${resolvedModel}`); + + // OpenCode CLI handles the system prompt, so combine them + const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; + enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); } else { // Use Claude SDK for Claude models logger.info(`Using Claude provider for model: ${resolvedModel}`); diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 4f62ee177..e0435f358 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js'; import { createCreateHandler } from './routes/create.js'; import { createUpdateHandler } from './routes/update.js'; import { createBulkUpdateHandler } from './routes/bulk-update.js'; +import { createBulkDeleteHandler } from './routes/bulk-delete.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; @@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { validatePathParams('projectPath'), createBulkUpdateHandler(featureLoader) ); + router.post( + '/bulk-delete', + validatePathParams('projectPath'), + createBulkDeleteHandler(featureLoader) + ); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); diff --git a/apps/server/src/routes/features/routes/bulk-delete.ts b/apps/server/src/routes/features/routes/bulk-delete.ts new file mode 100644 index 000000000..555515ae3 --- /dev/null +++ b/apps/server/src/routes/features/routes/bulk-delete.ts @@ -0,0 +1,61 @@ +/** + * POST /bulk-delete endpoint - Delete multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkDeleteRequest { + projectPath: string; + featureIds: string[]; +} + +interface BulkDeleteResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds } = req.body as BulkDeleteRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + const results = await Promise.all( + featureIds.map(async (featureId) => { + const success = await featureLoader.delete(projectPath, featureId); + if (success) { + return { featureId, success: true }; + } + return { + featureId, + success: false, + error: 'Deletion failed. Check server logs for details.', + }; + }) + ); + + const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); + const failureCount = results.length - successCount; + + res.json({ + success: failureCount === 0, + deletedCount: successCount, + failedCount: failureCount, + results, + }); + } catch (error) { + logError(error, 'Bulk delete features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 2e960a625..1a89cda3a 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js'; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } = - req.body as { - projectPath: string; - featureId: string; - updates: Partial; - descriptionHistorySource?: 'enhance' | 'edit'; - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'; - }; + const { + projectPath, + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription, + } = req.body as { + projectPath: string; + featureId: string; + updates: Partial; + descriptionHistorySource?: 'enhance' | 'edit'; + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'; + preEnhancementDescription?: string; + }; if (!projectPath || !featureId || !updates) { res.status(400).json({ @@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { featureId, updates, descriptionHistorySource, - enhancementMode + enhancementMode, + preEnhancementDescription ); res.json({ success: true, feature: updated }); } catch (error) { diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index fe38a14e5..a35c5e6b2 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js'; import { createAuthOpencodeHandler } from './routes/auth-opencode.js'; import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js'; import { createOpencodeStatusHandler } from './routes/opencode-status.js'; +import { + createGetOpencodeModelsHandler, + createRefreshOpencodeModelsHandler, + createGetOpencodeProvidersHandler, + createClearOpencodeCacheHandler, +} from './routes/opencode-models.js'; import { createGetCursorConfigHandler, createSetCursorDefaultModelHandler, @@ -65,6 +71,12 @@ export function createSetupRoutes(): Router { router.get('/opencode-status', createOpencodeStatusHandler()); router.post('/auth-opencode', createAuthOpencodeHandler()); router.post('/deauth-opencode', createDeauthOpencodeHandler()); + + // OpenCode Dynamic Model Discovery routes + router.get('/opencode/models', createGetOpencodeModelsHandler()); + router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); + router.get('/opencode/providers', createGetOpencodeProvidersHandler()); + router.post('/opencode/cache/clear', createClearOpencodeCacheHandler()); router.get('/cursor-config', createGetCursorConfigHandler()); router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler()); router.post('/cursor-config/models', createSetCursorModelsHandler()); diff --git a/apps/server/src/routes/setup/routes/opencode-models.ts b/apps/server/src/routes/setup/routes/opencode-models.ts new file mode 100644 index 000000000..a3b2b7bee --- /dev/null +++ b/apps/server/src/routes/setup/routes/opencode-models.ts @@ -0,0 +1,189 @@ +/** + * OpenCode Dynamic Models API Routes + * + * Provides endpoints for: + * - GET /api/setup/opencode/models - Get available models (cached or refreshed) + * - POST /api/setup/opencode/models/refresh - Force refresh models from CLI + * - GET /api/setup/opencode/providers - Get authenticated providers + */ + +import type { Request, Response } from 'express'; +import { + OpencodeProvider, + type OpenCodeProviderInfo, +} from '../../../providers/opencode-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import type { ModelDefinition } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OpenCodeModelsRoute'); + +// Singleton provider instance for caching +let providerInstance: OpencodeProvider | null = null; + +function getProvider(): OpencodeProvider { + if (!providerInstance) { + providerInstance = new OpencodeProvider(); + } + return providerInstance; +} + +/** + * Response type for models endpoint + */ +interface ModelsResponse { + success: boolean; + models?: ModelDefinition[]; + count?: number; + cached?: boolean; + error?: string; +} + +/** + * Response type for providers endpoint + */ +interface ProvidersResponse { + success: boolean; + providers?: OpenCodeProviderInfo[]; + authenticated?: OpenCodeProviderInfo[]; + error?: string; +} + +/** + * Creates handler for GET /api/setup/opencode/models + * + * Returns currently available models (from cache if available). + * Query params: + * - refresh=true: Force refresh from CLI before returning + * + * Note: If cache is empty, this will trigger a refresh to get dynamic models. + */ +export function createGetOpencodeModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const forceRefresh = req.query.refresh === 'true'; + + let models: ModelDefinition[]; + let cached = true; + + if (forceRefresh) { + models = await provider.refreshModels(); + cached = false; + } else { + // Check if we have cached models + const cachedModels = provider.getAvailableModels(); + + // If cache only has default models (provider.hasCachedModels() would be false), + // trigger a refresh to get dynamic models + if (!provider.hasCachedModels()) { + models = await provider.refreshModels(); + cached = false; + } else { + models = cachedModels; + } + } + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/models/refresh + * + * Forces a refresh of models from the OpenCode CLI. + */ +export function createRefreshOpencodeModelsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const models = await provider.refreshModels(); + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached: false, + }; + + res.json(response); + } catch (error) { + logError(error, 'Refresh OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for GET /api/setup/opencode/providers + * + * Returns authenticated providers from OpenCode CLI. + * This calls `opencode auth list` to get provider status. + */ +export function createGetOpencodeProvidersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const providers = await provider.fetchAuthenticatedProviders(); + + // Filter to only authenticated providers + const authenticated = providers.filter((p) => p.authenticated); + + const response: ProvidersResponse = { + success: true, + providers, + authenticated, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get OpenCode providers failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ProvidersResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/cache/clear + * + * Clears the model cache, forcing a fresh fetch on next access. + */ +export function createClearOpencodeCacheHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + provider.clearModelCache(); + + res.json({ + success: true, + message: 'OpenCode model cache cleared', + }); + } catch (error) { + logError(error, 'Clear OpenCode cache failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 4f63a382b..75c3a437f 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -3,15 +3,51 @@ */ import { createLogger } from '@automaker/utils'; +import { spawnProcess } from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; -import path from 'path'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; -import { FeatureLoader } from '../../services/feature-loader.js'; const logger = createLogger('Worktree'); export const execAsync = promisify(exec); -const featureLoader = new FeatureLoader(); + +// ============================================================================ +// Secure Command Execution +// ============================================================================ + +/** + * Execute git command with array arguments to prevent command injection. + * Uses spawnProcess from @automaker/platform for secure, cross-platform execution. + * + * @param args - Array of git command arguments (e.g., ['worktree', 'add', path]) + * @param cwd - Working directory to execute the command in + * @returns Promise resolving to stdout output + * @throws Error with stderr message if command fails + * + * @example + * ```typescript + * // Safe: no injection possible + * await execGitCommand(['branch', '-D', branchName], projectPath); + * + * // Instead of unsafe: + * // await execAsync(`git branch -D ${branchName}`, { cwd }); + * ``` + */ +export async function execGitCommand(args: string[], cwd: string): Promise { + const result = await spawnProcess({ + command: 'git', + args, + cwd, + }); + + // spawnProcess returns { stdout, stderr, exitCode } + if (result.exitCode === 0) { + return result.stdout; + } else { + const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`; + throw new Error(errorMessage); + } +} // ============================================================================ // Constants @@ -99,18 +135,6 @@ export function normalizePath(p: string): string { return p.replace(/\\/g, '/'); } -/** - * Check if a path is a git repo - */ -export async function isGitRepo(repoPath: string): Promise { - try { - await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath }); - return true; - } catch { - return false; - } -} - /** * Check if a git repository has at least one commit (i.e., HEAD exists) * Returns false for freshly initialized repos with no commits diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6ed..a00e0bfeb 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -3,6 +3,7 @@ */ import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js'; import { createInfoHandler } from './routes/info.js'; @@ -24,14 +25,22 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js'; import { createOpenInEditorHandler, createGetDefaultEditorHandler, + createGetAvailableEditorsHandler, + createRefreshEditorsHandler, } from './routes/open-in-editor.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { + createGetInitScriptHandler, + createPutInitScriptHandler, + createDeleteInitScriptHandler, + createRunInitScriptHandler, +} from './routes/init-script.js'; -export function createWorktreeRoutes(): Router { +export function createWorktreeRoutes(events: EventEmitter): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -45,7 +54,7 @@ export function createWorktreeRoutes(): Router { requireValidProject, createMergeHandler() ); - router.post('/create', validatePathParams('projectPath'), createCreateHandler()); + router.post('/create', validatePathParams('projectPath'), createCreateHandler(events)); router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post('/create-pr', createCreatePRHandler()); router.post('/pr-info', createPRInfoHandler()); @@ -77,6 +86,8 @@ export function createWorktreeRoutes(): Router { router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.get('/default-editor', createGetDefaultEditorHandler()); + router.get('/available-editors', createGetAvailableEditorsHandler()); + router.post('/refresh-editors', createRefreshEditorsHandler()); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); router.post( @@ -87,5 +98,15 @@ export function createWorktreeRoutes(): Router { router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + // Init script routes + router.get('/init-script', createGetInitScriptHandler()); + router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); + router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + router.post( + '/run-init-script', + validatePathParams('projectPath', 'worktreePath'), + createRunInitScriptHandler(events) + ); + return router; } diff --git a/apps/server/src/routes/worktree/middleware.ts b/apps/server/src/routes/worktree/middleware.ts index d933fff40..eb83377f6 100644 --- a/apps/server/src/routes/worktree/middleware.ts +++ b/apps/server/src/routes/worktree/middleware.ts @@ -3,7 +3,8 @@ */ import type { Request, Response, NextFunction } from 'express'; -import { isGitRepo, hasCommits } from './common.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { hasCommits } from './common.js'; interface ValidationOptions { /** Check if the path is a git repository (default: true) */ diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index b8e075703..061fa8015 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,15 +12,19 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { isGitRepo } from '@automaker/git-utils'; import { - isGitRepo, getErrorMessage, logError, normalizePath, ensureInitialCommit, + isValidBranchName, + execGitCommand, } from '../common.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; +import { runInitScript } from '../../../services/init-script-service.js'; const logger = createLogger('Worktree'); @@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch( } } -export function createCreateHandler() { +export function createCreateHandler(events: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, baseBranch } = req.body as { @@ -94,6 +98,26 @@ export function createCreateHandler() { return; } + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + // Validate base branch if provided + if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') { + res.status(400).json({ + success: false, + error: + 'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + if (!(await isGitRepo(projectPath))) { res.status(400).json({ success: false, @@ -143,30 +167,28 @@ export function createCreateHandler() { // Create worktrees directory if it doesn't exist await secureFs.mkdir(worktreesDir, { recursive: true }); - // Check if branch exists + // Check if branch exists (using array arguments to prevent injection) let branchExists = false; try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: projectPath, - }); + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); branchExists = true; } catch { // Branch doesn't exist } - // Create worktree - let createCmd: string; + // Create worktree (using array arguments to prevent injection) if (branchExists) { // Use existing branch - createCmd = `git worktree add "${worktreePath}" ${branchName}`; + await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath); } else { // Create new branch from base or HEAD const base = baseBranch || 'HEAD'; - createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`; + await execGitCommand( + ['worktree', 'add', '-b', branchName, worktreePath, base], + projectPath + ); } - await execAsync(createCmd, { cwd: projectPath }); - // Note: We intentionally do NOT symlink .automaker to worktrees // Features and config are always accessed from the main project path // This avoids symlink loop issues when activating worktrees @@ -177,6 +199,8 @@ export function createCreateHandler() { // Resolve to absolute path for cross-platform compatibility // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); + + // Respond immediately (non-blocking) res.json({ success: true, worktree: { @@ -185,6 +209,17 @@ export function createCreateHandler() { isNew: !branchExists, }, }); + + // Trigger init script asynchronously after response + // runInitScript internally checks if script exists and hasn't already run + runInitScript({ + projectPath, + worktreePath: absoluteWorktreePath, + branch: branchName, + emitter: events, + }).catch((err) => { + logger.error(`Init script failed for ${branchName}:`, err); + }); } catch (error) { logError(error, 'Create worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts index 93857f787..6814add91 100644 --- a/apps/server/src/routes/worktree/routes/delete.ts +++ b/apps/server/src/routes/worktree/routes/delete.ts @@ -6,9 +6,11 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError } from '../common.js'; +import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); export function createDeleteHandler() { return async (req: Request, res: Response): Promise => { @@ -46,22 +48,28 @@ export function createDeleteHandler() { // Could not get branch name } - // Remove the worktree + // Remove the worktree (using array arguments to prevent injection) try { - await execAsync(`git worktree remove "${worktreePath}" --force`, { - cwd: projectPath, - }); + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); } catch (error) { // Try with prune if remove fails - await execAsync('git worktree prune', { cwd: projectPath }); + await execGitCommand(['worktree', 'prune'], projectPath); } // Optionally delete the branch + let branchDeleted = false; if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') { - try { - await execAsync(`git branch -D ${branchName}`, { cwd: projectPath }); - } catch { - // Branch deletion failed, not critical + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); + } else { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + // Branch deletion failed, not critical + logger.warn(`Failed to delete branch: ${branchName}`); + } } } @@ -69,7 +77,8 @@ export function createDeleteHandler() { success: true, deleted: { worktreePath, - branch: deleteBranch ? branchName : null, + branch: branchDeleted ? branchName : null, + branchDeleted, }, }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts new file mode 100644 index 000000000..e11dfd535 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -0,0 +1,280 @@ +/** + * Init Script routes - Read/write/run the worktree-init.sh file + * + * POST /init-script - Read the init script content + * PUT /init-script - Write content to the init script file + * DELETE /init-script - Delete the init script file + * POST /run-init-script - Run the init script for a worktree + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../../lib/events.js'; +import { forceRunInitScript } from '../../../services/init-script-service.js'; + +const logger = createLogger('InitScript'); + +/** Fixed path for init script within .automaker directory */ +const INIT_SCRIPT_FILENAME = 'worktree-init.sh'; + +/** Maximum allowed size for init scripts (1MB) */ +const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024; + +/** + * Get the full path to the init script for a project + */ +function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME); +} + +/** + * GET /init-script - Read the init script content + */ +export function createGetInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const rawProjectPath = req.query.projectPath; + + // Validate projectPath is a non-empty string (not an array or undefined) + if (!rawProjectPath || typeof rawProjectPath !== 'string') { + res.status(400).json({ + success: false, + error: 'projectPath query parameter is required', + }); + return; + } + + const projectPath = rawProjectPath.trim(); + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath cannot be empty', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + try { + const content = await secureFs.readFile(scriptPath, 'utf-8'); + res.json({ + success: true, + exists: true, + content: content as string, + path: scriptPath, + }); + } catch { + // File doesn't exist + res.json({ + success: true, + exists: false, + content: '', + path: scriptPath, + }); + } + } catch (error) { + logError(error, 'Read init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * PUT /init-script - Write content to the init script file + */ +export function createPutInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, content } = req.body as { + projectPath: string; + content: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ + success: false, + error: 'content must be a string', + }); + return; + } + + // Validate script size to prevent disk exhaustion + const sizeBytes = Buffer.byteLength(content, 'utf-8'); + if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) { + res.status(400).json({ + success: false, + error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`, + }); + return; + } + + // Log warning if potentially dangerous patterns are detected (non-blocking) + const dangerousPatterns = [ + /rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable) + /curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash + /wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(content)) { + logger.warn( + `Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.` + ); + } + } + + const scriptPath = getInitScriptPath(projectPath); + const automakerDir = path.dirname(scriptPath); + + // Ensure .automaker directory exists + await secureFs.mkdir(automakerDir, { recursive: true }); + + // Write the script content + await secureFs.writeFile(scriptPath, content, 'utf-8'); + + logger.info(`Wrote init script to ${scriptPath}`); + + res.json({ + success: true, + path: scriptPath, + }); + } catch (error) { + logError(error, 'Write init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * DELETE /init-script - Delete the init script file + */ +export function createDeleteInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + await secureFs.rm(scriptPath, { force: true }); + logger.info(`Deleted init script at ${scriptPath}`); + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Delete init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * POST /run-init-script - Run (or re-run) the init script for a worktree + */ +export function createRunInitScriptHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath, branch } = req.body as { + projectPath: string; + worktreePath: string; + branch: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!branch) { + res.status(400).json({ + success: false, + error: 'branch is required', + }); + return; + } + + // Validate branch name to prevent injection via environment variables + if (!isValidBranchName(branch)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + try { + await secureFs.access(scriptPath); + } catch { + res.status(404).json({ + success: false, + error: 'No init script found. Create one in Settings > Worktrees.', + }); + return; + } + + logger.info(`Running init script for branch "${branch}" (forced)`); + + // Run the script asynchronously (non-blocking) + forceRunInitScript({ + projectPath, + worktreePath, + branch, + emitter: events, + }); + + // Return immediately - progress will be streamed via WebSocket events + res.json({ + success: true, + message: 'Init script started', + }); + } catch (error) { + logError(error, 'Run init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 93d93dadc..bc70a341d 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -2,18 +2,23 @@ * POST /list endpoint - List all git worktrees * * Returns actual git worktrees from `git worktree list`. + * Also scans .worktrees/ directory to discover worktrees that may have been + * created externally or whose git state was corrupted. * Does NOT include tracked branches - only real worktrees with separate directories. */ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); interface WorktreeInfo { path: string; @@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise { } } +/** + * Scan the .worktrees directory to discover worktrees that may exist on disk + * but are not registered with git (e.g., created externally or corrupted state). + */ +async function scanWorktreesDirectory( + projectPath: string, + knownWorktreePaths: Set +): Promise> { + const discovered: Array<{ path: string; branch: string }> = []; + const worktreesDir = path.join(projectPath, '.worktrees'); + + try { + // Check if .worktrees directory exists + await secureFs.access(worktreesDir); + } catch { + // .worktrees directory doesn't exist + return discovered; + } + + try { + const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + const normalizedPath = normalizePath(worktreePath); + + // Skip if already known from git worktree list + if (knownWorktreePaths.has(normalizedPath)) continue; + + // Check if this is a valid git repository + const gitPath = path.join(worktreePath, '.git'); + try { + const gitStat = await secureFs.stat(gitPath); + + // Git worktrees have a .git FILE (not directory) that points to the parent repo + // Regular repos have a .git DIRECTORY + if (gitStat.isFile() || gitStat.isDirectory()) { + // Try to get the branch name + const branch = await getCurrentBranch(worktreePath); + if (branch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})` + ); + discovered.push({ + path: normalizedPath, + branch, + }); + } else { + // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + try { + const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const headBranch = headRef.trim(); + if (headBranch && headBranch !== 'HEAD') { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); + } + } catch { + // Can't determine branch, skip this directory + } + } + } + } catch { + // Not a git repo, skip + } + } + } catch (error) { + logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`); + } + + return discovered; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -116,6 +202,22 @@ export function createListHandler() { } } + // Scan .worktrees directory to discover worktrees that exist on disk + // but are not registered with git (e.g., created externally) + const knownPaths = new Set(worktrees.map((w) => w.path)); + const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths); + + // Add discovered worktrees to the list + for (const discovered of discoveredWorktrees) { + worktrees.push({ + path: discovered.path, + branch: discovered.branch, + isMain: false, + isCurrent: discovered.branch === currentBranch, + hasWorktree: true, + }); + } + // Read all worktree metadata to get PR info const allMetadata = await readAllWorktreeMetadata(projectPath); diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index 40e71b004..c5ea6f9eb 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -1,78 +1,40 @@ /** * POST /open-in-editor endpoint - Open a worktree directory in the default code editor * GET /default-editor endpoint - Get the name of the default code editor + * POST /refresh-editors endpoint - Clear editor cache and re-detect available editors + * + * This module uses @automaker/platform for cross-platform editor detection and launching. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { isAbsolute } from 'path'; +import { + clearEditorCache, + detectAllEditors, + detectDefaultEditor, + openInEditor, + openInFileManager, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; -const execAsync = promisify(exec); +const logger = createLogger('open-in-editor'); -// Editor detection with caching -interface EditorInfo { - name: string; - command: string; -} - -let cachedEditor: EditorInfo | null = null; - -/** - * Detect which code editor is available on the system - */ -async function detectDefaultEditor(): Promise { - // Return cached result if available - if (cachedEditor) { - return cachedEditor; - } - - // Try Cursor first (if user has Cursor, they probably prefer it) - try { - await execAsync('which cursor || where cursor'); - cachedEditor = { name: 'Cursor', command: 'cursor' }; - return cachedEditor; - } catch { - // Cursor not found - } - - // Try VS Code - try { - await execAsync('which code || where code'); - cachedEditor = { name: 'VS Code', command: 'code' }; - return cachedEditor; - } catch { - // VS Code not found - } - - // Try Zed - try { - await execAsync('which zed || where zed'); - cachedEditor = { name: 'Zed', command: 'zed' }; - return cachedEditor; - } catch { - // Zed not found - } - - // Try Sublime Text - try { - await execAsync('which subl || where subl'); - cachedEditor = { name: 'Sublime Text', command: 'subl' }; - return cachedEditor; - } catch { - // Sublime not found - } - - // Fallback to file manager - const platform = process.platform; - if (platform === 'darwin') { - cachedEditor = { name: 'Finder', command: 'open' }; - } else if (platform === 'win32') { - cachedEditor = { name: 'Explorer', command: 'explorer' }; - } else { - cachedEditor = { name: 'File Manager', command: 'xdg-open' }; - } - return cachedEditor; +export function createGetAvailableEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const editors = await detectAllEditors(); + res.json({ + success: true, + result: { + editors, + }, + }); + } catch (error) { + logError(error, 'Get available editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; } export function createGetDefaultEditorHandler() { @@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() { }; } +/** + * Handler to refresh the editor cache and re-detect available editors + * Useful when the user has installed/uninstalled editors + */ +export function createRefreshEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearEditorCache(); + + // Re-detect editors (this will repopulate the cache) + const editors = await detectAllEditors(); + + logger.info(`Editor cache refreshed, found ${editors.length} editors`); + + res.json({ + success: true, + result: { + editors, + message: `Found ${editors.length} available editors`, + }, + }); + } catch (error) { + logError(error, 'Refresh editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + export function createOpenInEditorHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, editorCommand } = req.body as { worktreePath: string; + editorCommand?: string; }; if (!worktreePath) { @@ -108,42 +100,44 @@ export function createOpenInEditorHandler() { return; } - const editor = await detectDefaultEditor(); + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } try { - await execAsync(`${editor.command} "${worktreePath}"`); + // Use the platform utility to open in editor + const result = await openInEditor(worktreePath, editorCommand); res.json({ success: true, result: { - message: `Opened ${worktreePath} in ${editor.name}`, - editorName: editor.name, + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, }, }); } catch (editorError) { - // If the detected editor fails, try opening in default file manager as fallback - const platform = process.platform; - let openCommand: string; - let fallbackName: string; - - if (platform === 'darwin') { - openCommand = `open "${worktreePath}"`; - fallbackName = 'Finder'; - } else if (platform === 'win32') { - openCommand = `explorer "${worktreePath}"`; - fallbackName = 'Explorer'; - } else { - openCommand = `xdg-open "${worktreePath}"`; - fallbackName = 'File Manager'; + // If the specified editor fails, try opening in default file manager as fallback + logger.warn( + `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` + ); + + try { + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); + } catch (fallbackError) { + // Both editor and file manager failed + throw fallbackError; } - - await execAsync(openCommand); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${fallbackName}`, - editorName: fallbackName, - }, - }); } } catch (error) { logError(error, 'Open in editor failed'); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index a2be666f9..b830a2974 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -31,7 +31,13 @@ import { const logger = createLogger('AutoMode'); import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; +import { + getFeatureDir, + getAutomakerDir, + getFeaturesDir, + getExecutionStatePath, + ensureAutomakerDir, +} from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -201,6 +207,29 @@ interface AutoModeConfig { projectPath: string; } +/** + * Execution state for recovery after server restart + * Tracks which features were running and auto-loop configuration + */ +interface ExecutionState { + version: 1; + autoLoopWasRunning: boolean; + maxConcurrency: number; + projectPath: string; + runningFeatureIds: string[]; + savedAt: string; +} + +// Default empty execution state +const DEFAULT_EXECUTION_STATE: ExecutionState = { + version: 1, + autoLoopWasRunning: false, + maxConcurrency: 3, + projectPath: '', + runningFeatureIds: [], + savedAt: '', +}; + // Constants for consecutive failure tracking const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive @@ -322,6 +351,9 @@ export class AutoModeService { projectPath, }); + // Save execution state for recovery after restart + await this.saveExecutionState(projectPath); + // Note: Memory folder initialization is now handled by loadContextFiles // Run the loop in the background @@ -390,17 +422,23 @@ export class AutoModeService { */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; + const projectPath = this.config?.projectPath; this.autoLoopRunning = false; if (this.autoLoopAbortController) { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } + // Clear execution state when auto-loop is explicitly stopped + if (projectPath) { + await this.clearExecutionState(projectPath); + } + // Emit stop event immediately when user explicitly stops if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', - projectPath: this.config?.projectPath, + projectPath, }); } @@ -441,6 +479,11 @@ export class AutoModeService { }; this.runningFeatures.set(featureId, tempRunningFeature); + // Save execution state when feature starts + if (isAutoMode) { + await this.saveExecutionState(projectPath); + } + try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); @@ -695,6 +738,11 @@ export class AutoModeService { `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); this.runningFeatures.delete(featureId); + + // Update execution state after feature completes + if (this.autoLoopRunning && projectPath) { + await this.saveExecutionState(projectPath); + } } } @@ -2950,6 +2998,149 @@ Begin implementing task ${task.id} now.`; }); } + // ============================================================================ + // Execution State Persistence - For recovery after server restart + // ============================================================================ + + /** + * Save execution state to disk for recovery after server restart + */ + private async saveExecutionState(projectPath: string): Promise { + try { + await ensureAutomakerDir(projectPath); + const statePath = getExecutionStatePath(projectPath); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: this.autoLoopRunning, + maxConcurrency: this.config?.maxConcurrency ?? 3, + projectPath, + runningFeatureIds: Array.from(this.runningFeatures.keys()), + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); + } catch (error) { + logger.error('Failed to save execution state:', error); + } + } + + /** + * Load execution state from disk + */ + private async loadExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + const content = (await secureFs.readFile(statePath, 'utf-8')) as string; + const state = JSON.parse(content) as ExecutionState; + return state; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to load execution state:', error); + } + return DEFAULT_EXECUTION_STATE; + } + } + + /** + * Clear execution state (called on successful shutdown or when auto-loop stops) + */ + private async clearExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + await secureFs.unlink(statePath); + logger.info('Cleared execution state'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to clear execution state:', error); + } + } + } + + /** + * Check for and resume interrupted features after server restart + * This should be called during server initialization + */ + async resumeInterruptedFeatures(projectPath: string): Promise { + logger.info('Checking for interrupted features to resume...'); + + // Load all features and find those that were interrupted + const featuresDir = getFeaturesDir(projectPath); + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + const interruptedFeatures: Feature[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + try { + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; + const feature = JSON.parse(data) as Feature; + + // Check if feature was interrupted (in_progress or pipeline_*) + if ( + feature.status === 'in_progress' || + (feature.status && feature.status.startsWith('pipeline_')) + ) { + // Verify it has existing context (agent-output.md) + const featureDir = getFeatureDir(projectPath, feature.id); + const contextPath = path.join(featureDir, 'agent-output.md'); + try { + await secureFs.access(contextPath); + interruptedFeatures.push(feature); + logger.info( + `Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` + ); + } catch { + // No context file, skip this feature - it will be restarted fresh + logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); + } + } + } catch { + // Skip invalid features + } + } + } + + if (interruptedFeatures.length === 0) { + logger.info('No interrupted features found'); + return; + } + + logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_resuming_features', { + message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`, + projectPath, + featureIds: interruptedFeatures.map((f) => f.id), + features: interruptedFeatures.map((f) => ({ + id: f.id, + title: f.title, + status: f.status, + })), + }); + + // Resume each interrupted feature + for (const feature of interruptedFeatures) { + try { + logger.info(`Resuming feature: ${feature.id} (${feature.title})`); + // Use resumeFeature which will detect the existing context and continue + await this.resumeFeature(projectPath, feature.id, true); + } catch (error) { + logger.error(`Failed to resume feature ${feature.id}:`, error); + // Continue with other features + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info('No features directory found, nothing to resume'); + } else { + logger.error('Error checking for interrupted features:', error); + } + } + } + /** * Extract and record learnings from a completed feature * Uses a quick Claude call to identify important decisions and patterns diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 098ce29cc..64ace35d7 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import * as os from 'os'; import * as pty from 'node-pty'; import { ClaudeUsage } from '../routes/claude/types.js'; +import { createLogger } from '@automaker/utils'; /** * Claude Usage Service @@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js'; * - macOS: Uses 'expect' command for PTY * - Windows/Linux: Uses node-pty for PTY */ +const logger = createLogger('ClaudeUsage'); + export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout @@ -164,21 +167,40 @@ export class ClaudeUsageService { const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; - const ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + let ptyProcess: any = null; + + try { + ptyProcess = pty.spawn(shell, args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }); + } catch (spawnError) { + // pty.spawn() can throw synchronously if the native module fails to load + // or if PTY is not available in the current environment (e.g., containers without /dev/pts) + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + + // Return a user-friendly error instead of crashing + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); @@ -188,7 +210,7 @@ export class ClaudeUsageService { } }, this.timeout); - ptyProcess.onData((data) => { + ptyProcess.onData((data: string) => { output += data; // Check if we've seen the usage data (look for "Current session") @@ -196,12 +218,12 @@ export class ClaudeUsageService { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.kill('SIGTERM'); } }, 2000); @@ -212,14 +234,14 @@ export class ClaudeUsageService { // Fallback: if we see "Esc to cancel" but haven't seen usage data yet if (!hasSeenUsageData && output.includes('Esc to cancel')) { setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } }, 3000); } }); - ptyProcess.onExit(({ exitCode }) => { + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { clearTimeout(timeoutId); if (settled) return; settled = true; diff --git a/apps/server/src/services/codex-app-server-service.ts b/apps/server/src/services/codex-app-server-service.ts new file mode 100644 index 000000000..ecfb99da1 --- /dev/null +++ b/apps/server/src/services/codex-app-server-service.ts @@ -0,0 +1,212 @@ +import { spawn, type ChildProcess } from 'child_process'; +import readline from 'readline'; +import { findCodexCliPath } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import type { + AppServerModelResponse, + AppServerAccountResponse, + AppServerRateLimitsResponse, + JsonRpcRequest, +} from '@automaker/types'; + +const logger = createLogger('CodexAppServer'); + +/** + * CodexAppServerService + * + * Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol. + * Handles process spawning, JSON-RPC messaging, and cleanup. + * + * Connection strategy: Spawn on-demand (new process for each method call) + */ +export class CodexAppServerService { + private cachedCliPath: string | null = null; + + /** + * Check if Codex CLI is available on the system + */ + async isAvailable(): Promise { + this.cachedCliPath = await findCodexCliPath(); + return Boolean(this.cachedCliPath); + } + + /** + * Fetch available models from app-server + */ + async getModels(): Promise { + const result = await this.executeJsonRpc((sendRequest) => { + return sendRequest('model/list', {}); + }); + + if (result) { + logger.info(`[getModels] ✓ Fetched ${result.data.length} models`); + } + + return result; + } + + /** + * Fetch account information from app-server + */ + async getAccount(): Promise { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/read', { refreshToken: false }); + }); + } + + /** + * Fetch rate limits from app-server + */ + async getRateLimits(): Promise { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/rateLimits/read', {}); + }); + } + + /** + * Execute JSON-RPC requests via Codex app-server + * + * This method: + * 1. Spawns a new `codex app-server` process + * 2. Handles JSON-RPC initialization handshake + * 3. Executes user-provided requests + * 4. Cleans up the process + * + * @param requestFn - Function that receives sendRequest helper and returns a promise + * @returns Result of the JSON-RPC request or null on failure + */ + private async executeJsonRpc( + requestFn: (sendRequest: (method: string, params?: unknown) => Promise) => Promise + ): Promise { + let childProcess: ChildProcess | null = null; + + try { + const cliPath = this.cachedCliPath || (await findCodexCliPath()); + + if (!cliPath) { + return null; + } + + // On Windows, .cmd files must be run through shell + const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd'); + + childProcess = spawn(cliPath, ['app-server'], { + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'dumb', + }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: needsShell, + }); + + if (!childProcess.stdin || !childProcess.stdout) { + throw new Error('Failed to create stdio pipes'); + } + + // Setup readline for reading JSONL responses + const rl = readline.createInterface({ + input: childProcess.stdout, + crlfDelay: Infinity, + }); + + // Message ID counter for JSON-RPC + let messageId = 0; + const pendingRequests = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } + >(); + + // Process incoming messages + rl.on('line', (line) => { + if (!line.trim()) return; + + try { + const message = JSON.parse(line); + + // Handle response to our request + if ('id' in message && message.id !== undefined) { + const pending = pendingRequests.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'Unknown error')); + } else { + pending.resolve(message.result); + } + } + } + // Ignore notifications (no id field) + } catch { + // Ignore parse errors for non-JSON lines + } + }); + + // Helper to send JSON-RPC request and wait for response + const sendRequest = (method: string, params?: unknown): Promise => { + return new Promise((resolve, reject) => { + const id = ++messageId; + const request: JsonRpcRequest = { + method, + id, + params: params ?? {}, + }; + + // Set timeout for request (10 seconds) + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request timeout: ${method}`)); + }, 10000); + + pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + + childProcess!.stdin!.write(JSON.stringify(request) + '\n'); + }); + }; + + // Helper to send notification (no response expected) + const sendNotification = (method: string, params?: unknown): void => { + const notification = params ? { method, params } : { method }; + childProcess!.stdin!.write(JSON.stringify(notification) + '\n'); + }; + + // 1. Initialize the app-server + await sendRequest('initialize', { + clientInfo: { + name: 'automaker', + title: 'AutoMaker', + version: '1.0.0', + }, + }); + + // 2. Send initialized notification + sendNotification('initialized'); + + // 3. Execute user-provided requests + const result = await requestFn(sendRequest); + + // Clean up + rl.close(); + childProcess.kill('SIGTERM'); + + return result; + } catch (error) { + logger.error('[executeJsonRpc] Failed:', error); + return null; + } finally { + // Ensure process is killed + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGTERM'); + } + } + } +} diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts new file mode 100644 index 000000000..7e171428c --- /dev/null +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -0,0 +1,258 @@ +import path from 'path'; +import { secureFs } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import type { AppServerModel } from '@automaker/types'; +import type { CodexAppServerService } from './codex-app-server-service.js'; + +const logger = createLogger('CodexModelCache'); + +/** + * Codex model with UI-compatible format + */ +export interface CodexModel { + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; +} + +/** + * Cache structure stored on disk + */ +interface CodexModelCache { + models: CodexModel[]; + cachedAt: number; + ttl: number; +} + +/** + * CodexModelCacheService + * + * Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence. + * + * Features: + * - 1-hour TTL (configurable) + * - Atomic file writes (temp file + rename) + * - Thread-safe (deduplicates concurrent refresh requests) + * - Auto-bootstrap on service creation + * - Graceful fallback (returns empty array on errors) + */ +export class CodexModelCacheService { + private cacheFilePath: string; + private ttl: number; + private appServerService: CodexAppServerService; + private inFlightRefresh: Promise | null = null; + + constructor( + dataDir: string, + appServerService: CodexAppServerService, + ttl: number = 3600000 // 1 hour default + ) { + this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json'); + this.ttl = ttl; + this.appServerService = appServerService; + } + + /** + * Get models from cache or fetch if stale + * + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Array of Codex models (empty array if unavailable) + */ + async getModels(forceRefresh = false): Promise { + // If force refresh, skip cache + if (forceRefresh) { + return this.refreshModels(); + } + + // Try to load from cache + const cached = await this.loadFromCache(); + if (cached) { + const age = Date.now() - cached.cachedAt; + const isStale = age > cached.ttl; + + if (!isStale) { + logger.info( + `[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)` + ); + return cached.models; + } + } + + // Cache is stale or missing, refresh + return this.refreshModels(); + } + + /** + * Get models with cache metadata + * + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Object containing models and cache timestamp + */ + async getModelsWithMetadata( + forceRefresh = false + ): Promise<{ models: CodexModel[]; cachedAt: number }> { + const models = await this.getModels(forceRefresh); + + // Try to get the actual cache timestamp + const cached = await this.loadFromCache(); + const cachedAt = cached?.cachedAt ?? Date.now(); + + return { models, cachedAt }; + } + + /** + * Refresh models from app-server and update cache + * + * Thread-safe: Deduplicates concurrent refresh requests + */ + async refreshModels(): Promise { + // Deduplicate concurrent refresh requests + if (this.inFlightRefresh) { + return this.inFlightRefresh; + } + + // Start new refresh + this.inFlightRefresh = this.doRefresh(); + + try { + const models = await this.inFlightRefresh; + return models; + } finally { + this.inFlightRefresh = null; + } + } + + /** + * Clear the cache file + */ + async clearCache(): Promise { + logger.info('[clearCache] Clearing cache...'); + + try { + await secureFs.unlink(this.cacheFilePath); + logger.info('[clearCache] Cache cleared'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('[clearCache] Failed to clear cache:', error); + } + } + } + + /** + * Internal method to perform the actual refresh + */ + private async doRefresh(): Promise { + try { + // Check if app-server is available + const isAvailable = await this.appServerService.isAvailable(); + if (!isAvailable) { + return []; + } + + // Fetch models from app-server + const response = await this.appServerService.getModels(); + if (!response || !response.data) { + return []; + } + + // Transform models to UI format + const models = response.data.map((model) => this.transformModel(model)); + + // Save to cache + await this.saveToCache(models); + + logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`); + + return models; + } catch (error) { + logger.error('[doRefresh] Refresh failed:', error); + return []; + } + } + + /** + * Transform app-server model to UI-compatible format + */ + private transformModel(appServerModel: AppServerModel): CodexModel { + return { + id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility + label: appServerModel.displayName, + description: appServerModel.description, + hasThinking: appServerModel.supportedReasoningEfforts.length > 0, + supportsVision: true, // All Codex models support vision + tier: this.inferTier(appServerModel.id), + isDefault: appServerModel.isDefault, + }; + } + + /** + * Infer tier from model ID + */ + private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { + if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) { + return 'premium'; + } + if (modelId.includes('mini')) { + return 'basic'; + } + return 'standard'; + } + + /** + * Load cache from disk + */ + private async loadFromCache(): Promise { + try { + const content = await secureFs.readFile(this.cacheFilePath, 'utf-8'); + const cache = JSON.parse(content.toString()) as CodexModelCache; + + // Validate cache structure + if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') { + logger.warn('[loadFromCache] Invalid cache structure, ignoring'); + return null; + } + + return cache; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[loadFromCache] Failed to read cache:', error); + } + return null; + } + } + + /** + * Save cache to disk (atomic write) + */ + private async saveToCache(models: CodexModel[]): Promise { + const cache: CodexModelCache = { + models, + cachedAt: Date.now(), + ttl: this.ttl, + }; + + const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`; + + try { + // Write to temp file + const content = JSON.stringify(cache, null, 2); + await secureFs.writeFile(tempPath, content, 'utf-8'); + + // Atomic rename + await secureFs.rename(tempPath, this.cacheFilePath); + } catch (error) { + logger.error('[saveToCache] Failed to save cache:', error); + + // Clean up temp file + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index bf8aff992..e18d508e3 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -1,11 +1,11 @@ import { findCodexCliPath, - spawnProcess, getCodexAuthPath, systemPathExists, systemPathReadFile, } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; +import type { CodexAppServerService } from './codex-app-server-service.js'; const logger = createLogger('CodexUsage'); @@ -18,19 +18,12 @@ export interface CodexRateLimitWindow { resetsAt: number; } -export interface CodexCreditsSnapshot { - balance?: string; - unlimited?: boolean; - hasCredits?: boolean; -} - export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown'; export interface CodexUsageData { rateLimits: { primary?: CodexRateLimitWindow; secondary?: CodexRateLimitWindow; - credits?: CodexCreditsSnapshot; planType?: CodexPlanType; } | null; lastUpdated: string; @@ -39,13 +32,24 @@ export interface CodexUsageData { /** * Codex Usage Service * - * Attempts to fetch usage data from Codex CLI and OpenAI API. - * Codex CLI doesn't provide a direct usage command, but we can: - * 1. Parse usage info from error responses (rate limit errors contain plan info) - * 2. Check for OpenAI API usage if API key is available + * Fetches usage data from Codex CLI using the app-server JSON-RPC API. + * Falls back to auth file parsing if app-server is unavailable. */ export class CodexUsageService { private cachedCliPath: string | null = null; + private appServerService: CodexAppServerService | null = null; + private accountPlanTypeArray: CodexPlanType[] = [ + 'free', + 'plus', + 'pro', + 'team', + 'enterprise', + 'edu', + ]; + + constructor(appServerService?: CodexAppServerService) { + this.appServerService = appServerService || null; + } /** * Check if Codex CLI is available on the system @@ -58,60 +62,131 @@ export class CodexUsageService { /** * Attempt to fetch usage data * - * Tries multiple approaches: - * 1. Always try to get plan type from auth file first (authoritative source) - * 2. Check for OpenAI API key in environment for API usage - * 3. Make a test request to capture rate limit headers from CLI - * 4. Combine results from auth file and CLI + * Priority order: + * 1. Codex app-server JSON-RPC API (most reliable, provides real-time data) + * 2. Auth file JWT parsing (fallback for plan type) */ async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); const cliPath = this.cachedCliPath || (await findCodexCliPath()); if (!cliPath) { + logger.error('[fetchUsageData] Codex CLI not found'); throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex'); } - // Always try to get plan type from auth file first - this is the authoritative source - const authPlanType = await this.getPlanTypeFromAuthFile(); - - // Check if user has an API key that we can use - const hasApiKey = !!process.env.OPENAI_API_KEY; + logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); - if (hasApiKey) { - // Try to get usage from OpenAI API - const openaiUsage = await this.fetchOpenAIUsage(); - if (openaiUsage) { - // Merge with auth file plan type if available - if (authPlanType && openaiUsage.rateLimits) { - openaiUsage.rateLimits.planType = authPlanType; - } - return openaiUsage; - } + // Try to get usage from Codex app-server (most reliable method) + const appServerUsage = await this.fetchFromAppServer(); + if (appServerUsage) { + logger.info('[fetchUsageData] ✓ Fetched usage from app-server'); + return appServerUsage; } - // Try to get usage from Codex CLI by making a simple request - const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType); - if (codexUsage) { - return codexUsage; - } + logger.info('[fetchUsageData] App-server failed, trying auth file fallback...'); - // Fallback: try to parse full usage from auth file + // Fallback: try to parse usage from auth file const authUsage = await this.fetchFromAuthFile(); if (authUsage) { + logger.info('[fetchUsageData] ✓ Fetched usage from auth file'); return authUsage; } - // If all else fails, return a message with helpful information - throw new Error( - 'Codex usage statistics require additional configuration. ' + - 'To enable usage tracking:\n\n' + - '1. Set your OpenAI API key in the environment:\n' + - ' export OPENAI_API_KEY=sk-...\n\n' + - '2. Or check your usage at:\n' + - ' https://platform.openai.com/usage\n\n' + - 'Note: If using Codex CLI with ChatGPT OAuth authentication, ' + - 'usage data must be queried through your OpenAI account.' - ); + logger.info('[fetchUsageData] All methods failed, returning unknown'); + + // If all else fails, return unknown + return { + rateLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + } + + /** + * Fetch usage data from Codex app-server using JSON-RPC API + * This is the most reliable method as it gets real-time data from OpenAI + */ + private async fetchFromAppServer(): Promise { + try { + // Use CodexAppServerService if available + if (!this.appServerService) { + return null; + } + + // Fetch account and rate limits in parallel + const [accountResult, rateLimitsResult] = await Promise.all([ + this.appServerService.getAccount(), + this.appServerService.getRateLimits(), + ]); + + if (!accountResult) { + return null; + } + + // Build response + // Prefer planType from rateLimits (more accurate/current) over account (can be stale) + let planType: CodexPlanType = 'unknown'; + + // First try rate limits planType (most accurate) + const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; + if (rateLimitsPlanType) { + const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + // Fall back to account planType if rate limits didn't have it + if (planType === 'unknown' && accountResult.account?.planType) { + const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType; + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + const result: CodexUsageData = { + rateLimits: { + planType, + }, + lastUpdated: new Date().toISOString(), + }; + + // Add rate limit info if available + if (rateLimitsResult?.rateLimits?.primary) { + const primary = rateLimitsResult.rateLimits.primary; + result.rateLimits!.primary = { + limit: -1, // Not provided by API + used: -1, // Not provided by API + remaining: -1, // Not provided by API + usedPercent: primary.usedPercent, + windowDurationMins: primary.windowDurationMins, + resetsAt: primary.resetsAt, + }; + } + + // Add secondary rate limit if available + if (rateLimitsResult?.rateLimits?.secondary) { + const secondary = rateLimitsResult.rateLimits.secondary; + result.rateLimits!.secondary = { + limit: -1, // Not provided by API + used: -1, // Not provided by API + remaining: -1, // Not provided by API + usedPercent: secondary.usedPercent, + windowDurationMins: secondary.windowDurationMins, + resetsAt: secondary.resetsAt, + }; + } + + logger.info( + `[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%` + ); + return result; + } catch (error) { + logger.error('[fetchFromAppServer] Failed:', error); + return null; + } } /** @@ -121,9 +196,11 @@ export class CodexUsageService { private async getPlanTypeFromAuthFile(): Promise { try { const authFilePath = getCodexAuthPath(); - const exists = await systemPathExists(authFilePath); + logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`); + const exists = systemPathExists(authFilePath); if (!exists) { + logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } @@ -131,16 +208,24 @@ export class CodexUsageService { const authData = JSON.parse(authContent); if (!authData.tokens?.id_token) { + logger.info('[getPlanTypeFromAuthFile] No id_token in auth file'); return 'unknown'; } const claims = this.parseJwt(authData.tokens.id_token); if (!claims) { + logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT'); return 'unknown'; } + logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims)); + // Extract plan type from nested OpenAI auth object with type validation const openaiAuthClaim = claims['https://api.openai.com/auth']; + logger.info( + '[getPlanTypeFromAuthFile] OpenAI auth claim:', + JSON.stringify(openaiAuthClaim, null, 2) + ); let accountType: string | undefined; let isSubscriptionExpired = false; @@ -188,154 +273,23 @@ export class CodexUsageService { } if (accountType) { - const normalizedType = accountType.toLowerCase(); - if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) { - return normalizedType as CodexPlanType; - } - } - } catch (error) { - logger.error('Failed to get plan type from auth file:', error); - } - - return 'unknown'; - } - - /** - * Try to fetch usage from OpenAI API using the API key - */ - private async fetchOpenAIUsage(): Promise { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - return null; - } - - try { - const endTime = Math.floor(Date.now() / 1000); - const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days - - const response = await fetch( - `https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - } - ); - - if (response.ok) { - const data = await response.json(); - return this.parseOpenAIUsage(data); - } - } catch (error) { - logger.error('Failed to fetch from OpenAI API:', error); - } - - return null; - } - - /** - * Parse OpenAI usage API response - */ - private parseOpenAIUsage(data: any): CodexUsageData { - let totalInputTokens = 0; - let totalOutputTokens = 0; - - if (data.data && Array.isArray(data.data)) { - for (const bucket of data.data) { - if (bucket.results && Array.isArray(bucket.results)) { - for (const result of bucket.results) { - totalInputTokens += result.input_tokens || 0; - totalOutputTokens += result.output_tokens || 0; - } - } - } - } - - return { - rateLimits: { - planType: 'unknown', - credits: { - hasCredits: true, - }, - }, - lastUpdated: new Date().toISOString(), - }; - } - - /** - * Try to fetch usage by making a test request to Codex CLI - * and parsing rate limit information from the response - */ - private async fetchCodexUsage( - cliPath: string, - authPlanType: CodexPlanType - ): Promise { - try { - // Make a simple request to trigger rate limit info if at limit - const result = await spawnProcess({ - command: cliPath, - args: ['exec', '--', 'echo', 'test'], - cwd: process.cwd(), - env: { - ...process.env, - TERM: 'dumb', - }, - timeout: 10000, - }); - - // Parse the output for rate limit information - const combinedOutput = (result.stdout + result.stderr).toLowerCase(); - - // Check if we got a rate limit error - const rateLimitMatch = combinedOutput.match( - /usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/ - ); - - if (rateLimitMatch) { - // Rate limit error contains the plan type - use that as it's the most authoritative - const planType = rateLimitMatch[1] as CodexPlanType; - const resetsAt = parseInt(rateLimitMatch[2], 10); - const resetsInSeconds = parseInt(rateLimitMatch[3], 10); - + const normalizedType = accountType.toLowerCase() as CodexPlanType; logger.info( - `Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins` + `[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"` ); - - return { - rateLimits: { - planType, - primary: { - limit: 0, - used: 0, - remaining: 0, - usedPercent: 100, - windowDurationMins: Math.ceil(resetsInSeconds / 60), - resetsAt, - }, - }, - lastUpdated: new Date().toISOString(), - }; + if (this.accountPlanTypeArray.includes(normalizedType)) { + logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`); + return normalizedType; + } + } else { + logger.info('[getPlanTypeFromAuthFile] No account type found in claims'); } - - // No rate limit error - use the plan type from auth file - const isFreePlan = authPlanType === 'free'; - - return { - rateLimits: { - planType: authPlanType, - credits: { - hasCredits: true, - unlimited: !isFreePlan && authPlanType !== 'unknown', - }, - }, - lastUpdated: new Date().toISOString(), - }; } catch (error) { - logger.error('Failed to fetch from Codex CLI:', error); + logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error); } - return null; + logger.info('[getPlanTypeFromAuthFile] Returning unknown'); + return 'unknown'; } /** @@ -343,27 +297,27 @@ export class CodexUsageService { * Reuses getPlanTypeFromAuthFile to avoid code duplication */ private async fetchFromAuthFile(): Promise { + logger.info('[fetchFromAuthFile] Starting...'); try { const planType = await this.getPlanTypeFromAuthFile(); + logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`); if (planType === 'unknown') { + logger.info('[fetchFromAuthFile] Plan type unknown, returning null'); return null; } - const isFreePlan = planType === 'free'; - - return { + const result: CodexUsageData = { rateLimits: { planType, - credits: { - hasCredits: true, - unlimited: !isFreePlan, - }, }, lastUpdated: new Date().toISOString(), }; + + logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2)); + return result; } catch (error) { - logger.error('Failed to parse auth file:', error); + logger.error('[fetchFromAuthFile] Failed to parse auth file:', error); } return null; @@ -372,7 +326,7 @@ export class CodexUsageService { /** * Parse JWT token to extract claims */ - private parseJwt(token: string): any { + private parseJwt(token: string): Record | null { try { const parts = token.split('.'); @@ -383,18 +337,8 @@ export class CodexUsageService { const base64Url = parts[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - // Use Buffer for Node.js environment instead of atob - let jsonPayload: string; - if (typeof Buffer !== 'undefined') { - jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); - } else { - jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) - .join('') - ); - } + // Use Buffer for Node.js environment + const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); return JSON.parse(jsonPayload); } catch { diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 93cff796a..409abd2a8 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -308,13 +308,15 @@ export class FeatureLoader { * @param updates - Partial feature updates * @param descriptionHistorySource - Source of description change ('enhance' or 'edit') * @param enhancementMode - Enhancement mode if source is 'enhance' + * @param preEnhancementDescription - Description before enhancement (for restoring original) */ async update( projectPath: string, featureId: string, updates: Partial, descriptionHistorySource?: 'enhance' | 'edit', - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer', + preEnhancementDescription?: string ): Promise { const feature = await this.get(projectPath, featureId); if (!feature) { @@ -338,9 +340,31 @@ export class FeatureLoader { updates.description !== feature.description && updates.description.trim() ) { + const timestamp = new Date().toISOString(); + + // If this is an enhancement and we have the pre-enhancement description, + // add the original text to history first (so user can restore to it) + if ( + descriptionHistorySource === 'enhance' && + preEnhancementDescription && + preEnhancementDescription.trim() + ) { + // Check if this pre-enhancement text is different from the last history entry + const lastEntry = updatedHistory[updatedHistory.length - 1]; + if (!lastEntry || lastEntry.description !== preEnhancementDescription) { + const preEnhanceEntry: DescriptionHistoryEntry = { + description: preEnhancementDescription, + timestamp, + source: updatedHistory.length === 0 ? 'initial' : 'edit', + }; + updatedHistory = [...updatedHistory, preEnhanceEntry]; + } + } + + // Add the new/enhanced description to history const historyEntry: DescriptionHistoryEntry = { description: updates.description, - timestamp: new Date().toISOString(), + timestamp, source: descriptionHistorySource || 'edit', ...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}), }; diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts new file mode 100644 index 000000000..7731c5ee5 --- /dev/null +++ b/apps/server/src/services/init-script-service.ts @@ -0,0 +1,360 @@ +/** + * Init Script Service - Executes worktree initialization scripts + * + * Runs the .automaker/worktree-init.sh script after worktree creation. + * Uses Git Bash on Windows for cross-platform shell script compatibility. + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform'; +import { findCommand } from '../lib/cli-detection.js'; +import type { EventEmitter } from '../lib/events.js'; +import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js'; +import * as secureFs from '../lib/secure-fs.js'; + +const logger = createLogger('InitScript'); + +export interface InitScriptOptions { + /** Absolute path to the project root */ + projectPath: string; + /** Absolute path to the worktree directory */ + worktreePath: string; + /** Branch name for this worktree */ + branch: string; + /** Event emitter for streaming output */ + emitter: EventEmitter; +} + +interface ShellCommand { + shell: string; + args: string[]; +} + +/** + * Init Script Service + * + * Handles execution of worktree initialization scripts with cross-platform + * shell detection and proper streaming of output via WebSocket events. + */ +export class InitScriptService { + private cachedShellCommand: ShellCommand | null | undefined = undefined; + + /** + * Get the path to the init script for a project + */ + getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); + } + + /** + * Check if the init script has already been run for a worktree + */ + async hasInitScriptRun(projectPath: string, branch: string): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; + } + + /** + * Find the appropriate shell for running scripts + * Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH + */ + async findShellCommand(): Promise { + // Return cached result if available + if (this.cachedShellCommand !== undefined) { + return this.cachedShellCommand; + } + + if (process.platform === 'win32') { + // On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe) + // WSL bash may not be properly configured and causes ENOENT errors + + // First try known Git Bash installation paths + const gitBashPath = await findGitBashPath(); + if (gitBashPath) { + logger.debug(`Found Git Bash at: ${gitBashPath}`); + this.cachedShellCommand = { shell: gitBashPath, args: [] }; + return this.cachedShellCommand; + } + + // Fall back to finding bash in PATH, but skip WSL bash + const bashInPath = await findCommand(['bash']); + if (bashInPath && !bashInPath.toLowerCase().includes('system32')) { + logger.debug(`Found bash in PATH at: ${bashInPath}`); + this.cachedShellCommand = { shell: bashInPath, args: [] }; + return this.cachedShellCommand; + } + + logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.'); + this.cachedShellCommand = null; + return null; + } + + // Unix-like systems: use getShellPaths() and check existence + const shellPaths = getShellPaths(); + const posixShells = shellPaths.filter( + (p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh' + ); + + for (const shellPath of posixShells) { + try { + if (systemPathExists(shellPath)) { + this.cachedShellCommand = { shell: shellPath, args: [] }; + return this.cachedShellCommand; + } + } catch { + // Path not allowed or doesn't exist, continue + } + } + + // Ultimate fallback + if (systemPathExists('/bin/sh')) { + this.cachedShellCommand = { shell: '/bin/sh', args: [] }; + return this.cachedShellCommand; + } + + this.cachedShellCommand = null; + return null; + } + + /** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ + async runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = this.getInitScriptPath(projectPath); + + // Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY) + try { + await secureFs.access(scriptPath); + } catch { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await this.hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = await this.findShellCommand(); + if (!shellCmd) { + const error = + process.platform === 'win32' + ? 'Git Bash not found. Please install Git for Windows to run init scripts.' + : 'No shell found (/bin/bash or /bin/sh)'; + logger.error(error); + + // Update metadata with error, preserving existing metadata + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error, + }); + + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error, + }); + return; + } + + logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); + logger.debug(`Using shell: ${shellCmd.shell}`); + + // Update metadata to mark as running + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', + }); + + // Emit started event + emitter.emit('worktree:init-started', { + projectPath, + worktreePath, + branch, + }); + + // Build safe environment - only pass necessary variables, not all of process.env + // This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY + const safeEnv: Record = { + // Automaker-specific variables + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + + // Essential system variables + PATH: process.env.PATH || '', + HOME: process.env.HOME || '', + USER: process.env.USER || '', + TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp', + + // Shell and locale + SHELL: process.env.SHELL || '', + LANG: process.env.LANG || 'en_US.UTF-8', + LC_ALL: process.env.LC_ALL || '', + + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + + // Git configuration + GIT_TERMINAL_PROMPT: '0', + }; + + // Platform-specific additions + if (process.platform === 'win32') { + safeEnv.USERPROFILE = process.env.USERPROFILE || ''; + safeEnv.APPDATA = process.env.APPDATA || ''; + safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || ''; + safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows'; + safeEnv.TEMP = process.env.TEMP || ''; + } + + // Spawn the script with safe environment + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: safeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Stream stdout + child.stdout?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stdout', + content, + }); + }); + + // Stream stderr + child.stderr?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stderr', + content, + }); + }); + + // Handle completion + child.on('exit', async (code) => { + const success = code === 0; + const status = success ? 'success' : 'failed'; + + logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: status, + initScriptError: success ? undefined : `Exit code: ${code}`, + }); + + // Emit completion event + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success, + exitCode: code, + }); + }); + + child.on('error', async (error) => { + logger.error(`Init script error for branch "${branch}":`, error); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error.message, + }); + + // Emit completion with error + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error: error.message, + }); + }); + } + + /** + * Force re-run the worktree initialization script + * Ignores the initScriptRan flag - useful for testing or re-setup + */ + async forceRunInitScript(options: InitScriptOptions): Promise { + const { projectPath, branch } = options; + + // Reset the initScriptRan flag so the script will run + const metadata = await readWorktreeMetadata(projectPath, branch); + if (metadata) { + await writeWorktreeMetadata(projectPath, branch, { + ...metadata, + initScriptRan: false, + initScriptStatus: undefined, + initScriptError: undefined, + }); + } + + // Now run the script + await this.runInitScript(options); + } +} + +// Singleton instance for convenience +let initScriptService: InitScriptService | null = null; + +/** + * Get the singleton InitScriptService instance + */ +export function getInitScriptService(): InitScriptService { + if (!initScriptService) { + initScriptService = new InitScriptService(); + } + return initScriptService; +} + +// Export convenience functions that use the singleton +export const getInitScriptPath = (projectPath: string) => + getInitScriptService().getInitScriptPath(projectPath); + +export const hasInitScriptRun = (projectPath: string, branch: string) => + getInitScriptService().hasInitScriptRun(projectPath, branch); + +export const runInitScript = (options: InitScriptOptions) => + getInitScriptService().runInitScript(options); + +export const forceRunInitScript = (options: InitScriptOptions) => + getInitScriptService().forceRunInitScript(options); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 7acd2ed18..f1dfd45c3 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -22,7 +22,6 @@ import type { Credentials, ProjectSettings, KeyboardShortcuts, - AIProfile, ProjectRef, TrashedProjectRef, BoardBackgroundSettings, @@ -299,7 +298,6 @@ export class SettingsService { ignoreEmptyArrayOverwrite('trashedProjects'); ignoreEmptyArrayOverwrite('projectHistory'); ignoreEmptyArrayOverwrite('recentFolders'); - ignoreEmptyArrayOverwrite('aiProfiles'); ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); @@ -602,8 +600,6 @@ 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, - kanbanCardDetailLevel: - (appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard', maxConcurrency: (appState.maxConcurrency as number) || 3, defaultSkipTests: appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, @@ -617,18 +613,15 @@ export class SettingsService { : false, useWorktrees: appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true, - showProfilesOnly: (appState.showProfilesOnly as boolean) || false, defaultPlanningMode: (appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip', defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false, - defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null, muteDoneSound: (appState.muteDoneSound as boolean) || false, enhancementModel: (appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet', keyboardShortcuts: (appState.keyboardShortcuts as KeyboardShortcuts) || DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, - aiProfiles: (appState.aiProfiles as AIProfile[]) || [], projects: (appState.projects as ProjectRef[]) || [], trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], projectHistory: (appState.projectHistory as string[]) || [], diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index a92e706e4..98bce97f3 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -7,13 +7,11 @@ export type { ThemeMode, - KanbanCardDetailLevel, ModelAlias, PlanningMode, ThinkingLevel, ModelProvider, KeyboardShortcuts, - AIProfile, ProjectRef, TrashedProjectRef, ChatSessionRef, diff --git a/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/apps/server/tests/unit/lib/enhancement-prompts.test.ts index ab1398619..13d615550 100644 --- a/apps/server/tests/unit/lib/enhancement-prompts.test.ts +++ b/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -17,6 +17,14 @@ import { type EnhancementMode, } from '@/lib/enhancement-prompts.js'; +const ENHANCEMENT_MODES: EnhancementMode[] = [ + 'improve', + 'technical', + 'simplify', + 'acceptance', + 'ux-reviewer', +]; + describe('enhancement-prompts.ts', () => { describe('System Prompt Constants', () => { it('should have non-empty improve system prompt', () => { @@ -184,8 +192,7 @@ describe('enhancement-prompts.ts', () => { }); it('should work with all enhancement modes', () => { - const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance']; - modes.forEach((mode) => { + ENHANCEMENT_MODES.forEach((mode) => { const prompt = buildUserPrompt(mode, testText); expect(prompt).toContain(testText); expect(prompt.length).toBeGreaterThan(100); @@ -205,6 +212,7 @@ describe('enhancement-prompts.ts', () => { expect(isValidEnhancementMode('technical')).toBe(true); expect(isValidEnhancementMode('simplify')).toBe(true); expect(isValidEnhancementMode('acceptance')).toBe(true); + expect(isValidEnhancementMode('ux-reviewer')).toBe(true); }); it('should return false for invalid modes', () => { @@ -216,13 +224,12 @@ describe('enhancement-prompts.ts', () => { }); describe('getAvailableEnhancementModes', () => { - it('should return all four enhancement modes', () => { + it('should return all enhancement modes', () => { const modes = getAvailableEnhancementModes(); - expect(modes).toHaveLength(4); - expect(modes).toContain('improve'); - expect(modes).toContain('technical'); - expect(modes).toContain('simplify'); - expect(modes).toContain('acceptance'); + expect(modes).toHaveLength(ENHANCEMENT_MODES.length); + ENHANCEMENT_MODES.forEach((mode) => { + expect(modes).toContain(mode); + }); }); it('should return an array', () => { diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index b33217a85..57e2fc384 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -3,7 +3,7 @@ import { OpencodeProvider, resetToolUseIdCounter, } from '../../../src/providers/opencode-provider.js'; -import type { ProviderMessage } from '@automaker/types'; +import type { ProviderMessage, ModelDefinition } from '@automaker/types'; import { collectAsyncGenerator } from '../../utils/helpers.js'; import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform'; @@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => { }); describe('getAvailableModels', () => { - it('should return 10 models', () => { + it('should return 5 models', () => { const models = provider.getAvailableModels(); - expect(models).toHaveLength(10); + expect(models).toHaveLength(5); }); - it('should include Claude Sonnet 4.5 (Bedrock) as default', () => { - const models = provider.getAvailableModels(); - const sonnet = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' - ); - - expect(sonnet).toBeDefined(); - expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)'); - expect(sonnet?.provider).toBe('opencode'); - expect(sonnet?.default).toBe(true); - expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); - }); - - it('should include Claude Opus 4.5 (Bedrock)', () => { - const models = provider.getAvailableModels(); - const opus = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' - ); - - expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)'); - expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'); - }); - - it('should include Claude Haiku 4.5 (Bedrock)', () => { - const models = provider.getAvailableModels(); - const haiku = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' - ); - - expect(haiku).toBeDefined(); - expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)'); - expect(haiku?.tier).toBe('standard'); - }); - - it('should include free tier Big Pickle model', () => { + it('should include Big Pickle as default', () => { const models = provider.getAvailableModels(); const bigPickle = models.find((m) => m.id === 'opencode/big-pickle'); expect(bigPickle).toBeDefined(); expect(bigPickle?.name).toBe('Big Pickle (Free)'); + expect(bigPickle?.provider).toBe('opencode'); + expect(bigPickle?.default).toBe(true); expect(bigPickle?.modelString).toBe('opencode/big-pickle'); - expect(bigPickle?.tier).toBe('basic'); }); - it('should include DeepSeek R1 (Bedrock)', () => { + it('should include free tier GLM model', () => { const models = provider.getAvailableModels(); - const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0'); + const glm = models.find((m) => m.id === 'opencode/glm-4.7-free'); - expect(deepseek).toBeDefined(); - expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)'); - expect(deepseek?.tier).toBe('premium'); + expect(glm).toBeDefined(); + expect(glm?.name).toBe('GLM 4.7 Free'); + expect(glm?.tier).toBe('basic'); + }); + + it('should include free tier MiniMax model', () => { + const models = provider.getAvailableModels(); + const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free'); + + expect(minimax).toBeDefined(); + expect(minimax?.name).toBe('MiniMax M2.1 Free'); + expect(minimax?.tier).toBe('basic'); }); it('should have all models support tools', () => { @@ -128,6 +103,24 @@ describe('opencode-provider.ts', () => { }); }); + describe('parseModelsOutput', () => { + it('should parse nested provider model IDs', () => { + const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n'); + + const parseModelsOutput = ( + provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } + ).parseModelsOutput.bind(provider); + const models = parseModelsOutput(output); + + expect(models).toHaveLength(2); + const openrouterModel = models.find((model) => model.id.startsWith('openrouter/')); + + expect(openrouterModel).toBeDefined(); + expect(openrouterModel?.provider).toBe('openrouter'); + expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet'); + }); + }); + describe('supportsFeature', () => { it("should support 'tools' feature", () => { expect(provider.supportsFeature('tools')).toBe(true); @@ -168,41 +161,23 @@ describe('opencode-provider.ts', () => { it('should build correct args with run subcommand', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', cwd: '/tmp/project', }); expect(args[0]).toBe('run'); }); - it('should include --format stream-json for streaming output', () => { + it('should include --format json for streaming output', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', cwd: '/tmp/project', }); const formatIndex = args.indexOf('--format'); expect(formatIndex).toBeGreaterThan(-1); - expect(args[formatIndex + 1]).toBe('stream-json'); - }); - - it('should include -q flag for quiet mode', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/project', - }); - - expect(args).toContain('-q'); - }); - - it('should include working directory with -c flag', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/my-project', - }); - - const cwdIndex = args.indexOf('-c'); - expect(cwdIndex).toBeGreaterThan(-1); - expect(args[cwdIndex + 1]).toBe('/tmp/my-project'); + expect(args[formatIndex + 1]).toBe('json'); }); it('should include model with --model flag', () => { @@ -228,30 +203,24 @@ describe('opencode-provider.ts', () => { expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); }); - it('should include dash as final arg for stdin prompt', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/project', - }); - - expect(args[args.length - 1]).toBe('-'); - }); - it('should handle missing cwd', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', }); expect(args).not.toContain('-c'); }); - it('should handle missing model', () => { + it('should handle model from opencode provider', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', cwd: '/tmp/project', }); - expect(args).not.toContain('--model'); + expect(args).toContain('--model'); + expect(args).toContain('opencode/big-pickle'); }); }); @@ -260,12 +229,15 @@ describe('opencode-provider.ts', () => { // ========================================================================== describe('normalizeEvent', () => { - describe('text-delta events', () => { - it('should convert text-delta to assistant message with text content', () => { + describe('text events (new OpenCode format)', () => { + it('should convert text to assistant message with text content', () => { const event = { - type: 'text-delta', - text: 'Hello, world!', - session_id: 'test-session', + type: 'text', + part: { + type: 'text', + text: 'Hello, world!', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -285,33 +257,24 @@ describe('opencode-provider.ts', () => { }); }); - it('should return null for empty text-delta', () => { + it('should return null for empty text', () => { const event = { - type: 'text-delta', - text: '', - }; - - const result = provider.normalizeEvent(event); - - expect(result).toBeNull(); - }); - - it('should return null for text-delta with undefined text', () => { - const event = { - type: 'text-delta', + type: 'text', + part: { + type: 'text', + text: '', + }, }; const result = provider.normalizeEvent(event); expect(result).toBeNull(); }); - }); - describe('text-end events', () => { - it('should return null for text-end events (informational)', () => { + it('should return null for text with undefined text', () => { const event = { - type: 'text-end', - session_id: 'test-session', + type: 'text', + part: {}, }; const result = provider.normalizeEvent(event); @@ -320,14 +283,17 @@ describe('opencode-provider.ts', () => { }); }); - describe('tool-call events', () => { - it('should convert tool-call to assistant message with tool_use content', () => { + describe('tool_call events', () => { + it('should convert tool_call to assistant message with tool_use content', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Read', - args: { file_path: '/tmp/test.txt' }, - session_id: 'test-session', + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -351,9 +317,12 @@ describe('opencode-provider.ts', () => { it('should generate tool_use_id when call_id is missing', () => { const event = { - type: 'tool-call', - name: 'Write', - args: { content: 'test' }, + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Write', + args: { content: 'test' }, + }, }; const result = provider.normalizeEvent(event); @@ -363,21 +332,27 @@ describe('opencode-provider.ts', () => { // Second call should increment const result2 = provider.normalizeEvent({ - type: 'tool-call', - name: 'Edit', - args: {}, + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Edit', + args: {}, + }, }); expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2'); }); }); - describe('tool-result events', () => { - it('should convert tool-result to assistant message with tool_result content', () => { + describe('tool_result events', () => { + it('should convert tool_result to assistant message with tool_result content', () => { const event = { - type: 'tool-result', - call_id: 'call-123', - output: 'File contents here', - session_id: 'test-session', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: 'File contents here', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -398,10 +373,13 @@ describe('opencode-provider.ts', () => { }); }); - it('should handle tool-result without call_id', () => { + it('should handle tool_result without call_id', () => { const event = { - type: 'tool-result', - output: 'Result without ID', + type: 'tool_result', + part: { + type: 'tool-result', + output: 'Result without ID', + }, }; const result = provider.normalizeEvent(event); @@ -411,13 +389,16 @@ describe('opencode-provider.ts', () => { }); }); - describe('tool-error events', () => { - it('should convert tool-error to error message', () => { + describe('tool_error events', () => { + it('should convert tool_error to error message', () => { const event = { - type: 'tool-error', - call_id: 'call-123', - error: 'File not found', - session_id: 'test-session', + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + error: 'File not found', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -431,8 +412,11 @@ describe('opencode-provider.ts', () => { it('should provide default error message when error is missing', () => { const event = { - type: 'tool-error', - call_id: 'call-123', + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + }, }; const result = provider.normalizeEvent(event); @@ -442,12 +426,14 @@ describe('opencode-provider.ts', () => { }); }); - describe('start-step events', () => { - it('should return null for start-step events (informational)', () => { + describe('step_start events', () => { + it('should return null for step_start events (informational)', () => { const event = { - type: 'start-step', - step: 1, - session_id: 'test-session', + type: 'step_start', + part: { + type: 'step-start', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -456,14 +442,16 @@ describe('opencode-provider.ts', () => { }); }); - describe('finish-step events', () => { - it('should convert successful finish-step to result message', () => { + describe('step_finish events', () => { + it('should convert successful step_finish to result message', () => { const event = { - type: 'finish-step', - step: 1, - success: true, - result: 'Task completed successfully', - session_id: 'test-session', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Task completed successfully', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -476,13 +464,15 @@ describe('opencode-provider.ts', () => { }); }); - it('should convert finish-step with success=false to error message', () => { + it('should convert step_finish with error to error message', () => { const event = { - type: 'finish-step', - step: 1, - success: false, - error: 'Something went wrong', - session_id: 'test-session', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + error: 'Something went wrong', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -494,11 +484,13 @@ describe('opencode-provider.ts', () => { }); }); - it('should convert finish-step with error property to error message', () => { + it('should convert step_finish with error property to error message', () => { const event = { - type: 'finish-step', - step: 1, - error: 'Process failed', + type: 'step_finish', + part: { + type: 'step-finish', + error: 'Process failed', + }, }; const result = provider.normalizeEvent(event); @@ -509,9 +501,11 @@ describe('opencode-provider.ts', () => { it('should provide default error message for failed step without error text', () => { const event = { - type: 'finish-step', - step: 1, - success: false, + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + }, }; const result = provider.normalizeEvent(event); @@ -520,11 +514,14 @@ describe('opencode-provider.ts', () => { expect(result?.error).toBe('Step execution failed'); }); - it('should treat finish-step without success flag as success', () => { + it('should treat step_finish with reason=stop as success', () => { const event = { - type: 'finish-step', - step: 1, - result: 'Done', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Done', + }, }; const result = provider.normalizeEvent(event); @@ -586,13 +583,12 @@ describe('opencode-provider.ts', () => { return mockedProvider; } - it('should stream text-delta events as assistant messages', async () => { + it('should stream text events as assistant messages', async () => { const mockedProvider = setupMockedProvider(); const mockEvents = [ - { type: 'text-delta', text: 'Hello ' }, - { type: 'text-delta', text: 'World!' }, - { type: 'text-end' }, + { type: 'text', part: { type: 'text', text: 'Hello ' } }, + { type: 'text', part: { type: 'text', text: 'World!' } }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -611,7 +607,6 @@ describe('opencode-provider.ts', () => { }) ); - // text-end should be filtered out (returns null) expect(results).toHaveLength(2); expect(results[0].type).toBe('assistant'); expect(results[0].message?.content[0].text).toBe('Hello '); @@ -623,15 +618,21 @@ describe('opencode-provider.ts', () => { const mockEvents = [ { - type: 'tool-call', - call_id: 'tool-1', - name: 'Read', - args: { file_path: '/tmp/test.txt' }, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'tool-1', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, }, { - type: 'tool-result', - call_id: 'tool-1', - output: 'File contents', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'tool-1', + output: 'File contents', + }, }, ]; @@ -718,10 +719,7 @@ describe('opencode-provider.ts', () => { const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; expect(call.args).toContain('run'); expect(call.args).toContain('--format'); - expect(call.args).toContain('stream-json'); - expect(call.args).toContain('-q'); - expect(call.args).toContain('-c'); - expect(call.args).toContain('/tmp/workspace'); + expect(call.args).toContain('json'); expect(call.args).toContain('--model'); expect(call.args).toContain('anthropic/claude-opus-4-5'); }); @@ -731,9 +729,9 @@ describe('opencode-provider.ts', () => { const mockEvents = [ { type: 'unknown-internal-event', data: 'ignored' }, - { type: 'text-delta', text: 'Valid text' }, + { type: 'text', part: { type: 'text', text: 'Valid text' } }, { type: 'another-unknown', foo: 'bar' }, - { type: 'finish-step', result: 'Done' }, + { type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -747,6 +745,7 @@ describe('opencode-provider.ts', () => { const results = await collectAsyncGenerator( mockedProvider.executeQuery({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/test', }) ); @@ -1039,10 +1038,22 @@ describe('opencode-provider.ts', () => { const sessionId = 'test-session-123'; const mockEvents = [ - { type: 'text-delta', text: 'Hello ', session_id: sessionId }, - { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1', session_id: sessionId }, - { type: 'tool-result', call_id: 'c1', output: 'file content', session_id: sessionId }, - { type: 'finish-step', result: 'Done', session_id: sessionId }, + { type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId }, + { + type: 'tool_call', + part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' }, + sessionID: sessionId, + }, + { + type: 'tool_result', + part: { type: 'tool-result', call_id: 'c1', output: 'file content' }, + sessionID: sessionId, + }, + { + type: 'step_finish', + part: { type: 'step-finish', reason: 'stop', result: 'Done' }, + sessionID: sessionId, + }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -1056,6 +1067,7 @@ describe('opencode-provider.ts', () => { const results = await collectAsyncGenerator( mockedProvider.executeQuery({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/tmp', }) ); @@ -1069,12 +1081,15 @@ describe('opencode-provider.ts', () => { }); describe('normalizeEvent additional edge cases', () => { - it('should handle tool-call with empty args object', () => { + it('should handle tool_call with empty args object', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Glob', - args: {}, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: {}, + }, }; const result = provider.normalizeEvent(event); @@ -1083,12 +1098,15 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].input).toEqual({}); }); - it('should handle tool-call with null args', () => { + it('should handle tool_call with null args', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Glob', - args: null, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: null, + }, }; const result = provider.normalizeEvent(event); @@ -1097,18 +1115,21 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].input).toBeNull(); }); - it('should handle tool-call with complex nested args', () => { + it('should handle tool_call with complex nested args', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Edit', - args: { - file_path: '/tmp/test.ts', - changes: [ - { line: 10, old: 'foo', new: 'bar' }, - { line: 20, old: 'baz', new: 'qux' }, - ], - options: { replace_all: true }, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Edit', + args: { + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }, }, }; @@ -1125,11 +1146,14 @@ describe('opencode-provider.ts', () => { }); }); - it('should handle tool-result with empty output', () => { + it('should handle tool_result with empty output', () => { const event = { - type: 'tool-result', - call_id: 'call-123', - output: '', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: '', + }, }; const result = provider.normalizeEvent(event); @@ -1138,10 +1162,13 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].content).toBe(''); }); - it('should handle text-delta with whitespace-only text', () => { + it('should handle text with whitespace-only text', () => { const event = { - type: 'text-delta', - text: ' ', + type: 'text', + part: { + type: 'text', + text: ' ', + }, }; const result = provider.normalizeEvent(event); @@ -1151,10 +1178,13 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].text).toBe(' '); }); - it('should handle text-delta with newlines', () => { + it('should handle text with newlines', () => { const event = { - type: 'text-delta', - text: 'Line 1\nLine 2\nLine 3', + type: 'text', + part: { + type: 'text', + text: 'Line 1\nLine 2\nLine 3', + }, }; const result = provider.normalizeEvent(event); @@ -1162,12 +1192,15 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3'); }); - it('should handle finish-step with both result and error (error takes precedence)', () => { + it('should handle step_finish with both result and error (error takes precedence)', () => { const event = { - type: 'finish-step', - result: 'Some result', - error: 'But also an error', - success: false, + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Some result', + error: 'But also an error', + }, }; const result = provider.normalizeEvent(event); @@ -1203,7 +1236,7 @@ describe('opencode-provider.ts', () => { const defaultModels = models.filter((m) => m.default === true); expect(defaultModels).toHaveLength(1); - expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); + expect(defaultModels[0].id).toBe('opencode/big-pickle'); }); it('should have valid tier values for all models', () => { @@ -1231,13 +1264,14 @@ describe('opencode-provider.ts', () => { const longPrompt = 'a'.repeat(10000); const args = provider.buildCliArgs({ prompt: longPrompt, + model: 'opencode/big-pickle', cwd: '/tmp', }); // The prompt is NOT in args (it's passed via stdin) // Just verify the args structure is correct expect(args).toContain('run'); - expect(args).toContain('-'); + expect(args).not.toContain('-'); expect(args.join(' ')).not.toContain(longPrompt); }); @@ -1245,22 +1279,25 @@ describe('opencode-provider.ts', () => { const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\''; const args = provider.buildCliArgs({ prompt: specialPrompt, + model: 'opencode/big-pickle', cwd: '/tmp', }); // Special chars in prompt should not affect args (prompt is via stdin) expect(args).toContain('run'); - expect(args).toContain('-'); + expect(args).not.toContain('-'); }); it('should handle cwd with spaces', () => { const args = provider.buildCliArgs({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/tmp/path with spaces/project', }); - const cwdIndex = args.indexOf('-c'); - expect(args[cwdIndex + 1]).toBe('/tmp/path with spaces/project'); + // cwd is set at subprocess level, not via CLI args + expect(args).not.toContain('-c'); + expect(args).not.toContain('/tmp/path with spaces/project'); }); it('should handle model with unusual characters', () => { diff --git a/apps/server/tests/unit/routes/app-spec/common.test.ts b/apps/server/tests/unit/routes/app-spec/common.test.ts index aeaf8ea5c..a348a2aa4 100644 --- a/apps/server/tests/unit/routes/app-spec/common.test.ts +++ b/apps/server/tests/unit/routes/app-spec/common.test.ts @@ -5,59 +5,61 @@ import { getSpecRegenerationStatus, } from '@/routes/app-spec/common.js'; +const TEST_PROJECT_PATH = '/tmp/automaker-test-project'; + describe('app-spec/common.ts', () => { beforeEach(() => { // Reset state before each test - setRunningState(false, null); + setRunningState(TEST_PROJECT_PATH, false, null); }); describe('setRunningState', () => { it('should set isRunning to true when running is true', () => { - setRunningState(true); - expect(getSpecRegenerationStatus().isRunning).toBe(true); + setRunningState(TEST_PROJECT_PATH, true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); }); it('should set isRunning to false when running is false', () => { - setRunningState(true); - setRunningState(false); - expect(getSpecRegenerationStatus().isRunning).toBe(false); + setRunningState(TEST_PROJECT_PATH, true); + setRunningState(TEST_PROJECT_PATH, false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false); }); it('should set currentAbortController when provided', () => { const controller = new AbortController(); - setRunningState(true, controller); - expect(getSpecRegenerationStatus().currentAbortController).toBe(controller); + setRunningState(TEST_PROJECT_PATH, true, controller); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller); }); it('should set currentAbortController to null when not provided', () => { const controller = new AbortController(); - setRunningState(true, controller); - setRunningState(false); - expect(getSpecRegenerationStatus().currentAbortController).toBe(null); + setRunningState(TEST_PROJECT_PATH, true, controller); + setRunningState(TEST_PROJECT_PATH, false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null); }); - it('should set currentAbortController to null when explicitly passed null', () => { + it('should keep currentAbortController when explicitly passed null while running', () => { const controller = new AbortController(); - setRunningState(true, controller); - setRunningState(true, null); - expect(getSpecRegenerationStatus().currentAbortController).toBe(null); + setRunningState(TEST_PROJECT_PATH, true, controller); + setRunningState(TEST_PROJECT_PATH, true, null); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller); }); it('should update state multiple times correctly', () => { const controller1 = new AbortController(); const controller2 = new AbortController(); - setRunningState(true, controller1); - expect(getSpecRegenerationStatus().isRunning).toBe(true); - expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1); + setRunningState(TEST_PROJECT_PATH, true, controller1); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1); - setRunningState(true, controller2); - expect(getSpecRegenerationStatus().isRunning).toBe(true); - expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2); + setRunningState(TEST_PROJECT_PATH, true, controller2); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2); - setRunningState(false, null); - expect(getSpecRegenerationStatus().isRunning).toBe(false); - expect(getSpecRegenerationStatus().currentAbortController).toBe(null); + setRunningState(TEST_PROJECT_PATH, false, null); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null); }); }); diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts index b6bae8632..03d3d01c6 100644 --- a/apps/server/tests/unit/services/dev-server-service.test.ts +++ b/apps/server/tests/unit/services/dev-server-service.test.ts @@ -8,6 +8,7 @@ import fs from 'fs/promises'; vi.mock('child_process', () => ({ spawn: vi.fn(), execSync: vi.fn(), + execFile: vi.fn(), })); // Mock secure-fs diff --git a/apps/ui/package.json b/apps/ui/package.json index f5b5aa6e7..167734d38 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,6 +42,8 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", diff --git a/apps/ui/scripts/setup-e2e-fixtures.mjs b/apps/ui/scripts/setup-e2e-fixtures.mjs index e6009fd48..356e419b2 100644 --- a/apps/ui/scripts/setup-e2e-fixtures.mjs +++ b/apps/ui/scripts/setup-e2e-fixtures.mjs @@ -41,16 +41,13 @@ const E2E_SETTINGS = { theme: 'dark', sidebarOpen: true, chatHistoryOpen: false, - kanbanCardDetailLevel: 'standard', maxConcurrency: 3, defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, useWorktrees: true, - showProfilesOnly: false, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, - defaultAIProfileId: null, muteDoneSound: false, phaseModels: { enhancementModel: { model: 'sonnet' }, @@ -73,7 +70,6 @@ const E2E_SETTINGS = { spec: 'D', context: 'C', settings: 'S', - profiles: 'M', terminal: 'T', toggleSidebar: '`', addFeature: 'N', @@ -84,7 +80,6 @@ const E2E_SETTINGS = { projectPicker: 'P', cyclePrevProject: 'Q', cycleNextProject: 'E', - addProfile: 'N', splitTerminalRight: 'Alt+D', splitTerminalDown: 'Alt+S', closeTerminal: 'Alt+W', @@ -94,48 +89,6 @@ const E2E_SETTINGS = { githubPrs: 'R', newTerminalTab: 'Alt+T', }, - aiProfiles: [ - { - id: 'profile-heavy-task', - name: 'Heavy Task', - description: - 'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.', - model: 'opus', - thinkingLevel: 'ultrathink', - provider: 'claude', - isBuiltIn: true, - icon: 'Brain', - }, - { - id: 'profile-balanced', - name: 'Balanced', - description: 'Claude Sonnet with medium thinking for typical development tasks.', - model: 'sonnet', - thinkingLevel: 'medium', - provider: 'claude', - isBuiltIn: true, - icon: 'Scale', - }, - { - id: 'profile-quick-edit', - name: 'Quick Edit', - description: 'Claude Haiku for fast, simple edits and minor fixes.', - model: 'haiku', - thinkingLevel: 'none', - provider: 'claude', - isBuiltIn: true, - icon: 'Zap', - }, - { - id: 'profile-cursor-refactoring', - name: 'Cursor Refactoring', - description: 'Cursor Composer 1 for refactoring tasks.', - provider: 'cursor', - cursorModel: 'composer-1', - isBuiltIn: true, - icon: 'Sparkles', - }, - ], // Default test project using the fixture path - tests can override via route mocking if needed projects: [ { diff --git a/apps/ui/src/components/icons/editor-icons.tsx b/apps/ui/src/components/icons/editor-icons.tsx new file mode 100644 index 000000000..a4537d5f4 --- /dev/null +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -0,0 +1,220 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { FolderOpen } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const; +const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS; + +/** + * Cursor editor logo icon - from LobeHub icons + */ +export function CursorIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code editor logo icon + */ +export function VSCodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code Insiders editor logo icon (same as VS Code) + */ +export function VSCodeInsidersIcon(props: IconProps) { + return ; +} + +/** + * Kiro editor logo icon (VS Code fork) + */ +export function KiroIcon(props: IconProps) { + return ( + + + + + ); +} + +/** + * Zed editor logo icon (from Simple Icons) + */ +export function ZedIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Sublime Text editor logo icon + */ +export function SublimeTextIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Finder icon + */ +export function FinderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windsurf editor logo icon (by Codeium) - from LobeHub icons + */ +export function WindsurfIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Trae editor logo icon (by ByteDance) - from LobeHub icons + */ +export function TraeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains Rider logo icon + */ +export function RiderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains WebStorm logo icon + */ +export function WebStormIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Xcode logo icon + */ +export function XcodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Android Studio logo icon + */ +export function AndroidStudioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Google Antigravity IDE logo icon - stylized "A" arch shape + */ +export function AntigravityIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for an editor command + */ +export function getEditorIcon(command: string): IconComponent { + // Handle direct CLI commands + const cliIcons: Record = { + cursor: CursorIcon, + code: VSCodeIcon, + 'code-insiders': VSCodeInsidersIcon, + kido: KiroIcon, + zed: ZedIcon, + subl: SublimeTextIcon, + windsurf: WindsurfIcon, + trae: TraeIcon, + rider: RiderIcon, + webstorm: WebStormIcon, + xed: XcodeIcon, + studio: AndroidStudioIcon, + [PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + [LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + open: FinderIcon, + explorer: FolderOpen, + 'xdg-open': FolderOpen, + }; + + // Check direct match first + if (cliIcons[command]) { + return cliIcons[command]; + } + + // Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"' + if (command.startsWith('open')) { + const cmdLower = command.toLowerCase(); + if (cmdLower.includes('cursor')) return CursorIcon; + if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon; + if (cmdLower.includes('visual studio code')) return VSCodeIcon; + if (cmdLower.includes('kiro')) return KiroIcon; + if (cmdLower.includes('zed')) return ZedIcon; + if (cmdLower.includes('sublime')) return SublimeTextIcon; + if (cmdLower.includes('windsurf')) return WindsurfIcon; + if (cmdLower.includes('trae')) return TraeIcon; + if (cmdLower.includes('rider')) return RiderIcon; + if (cmdLower.includes('webstorm')) return WebStormIcon; + if (cmdLower.includes('xcode')) return XcodeIcon; + if (cmdLower.includes('android studio')) return AndroidStudioIcon; + if (cmdLower.includes('antigravity')) return AntigravityIcon; + // If just 'open' without app name, it's Finder + if (command === 'open') return FinderIcon; + } + + return FolderOpen; +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a1d03e87a..a8c70cb62 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -17,7 +17,6 @@ import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CollapseToggleButton, SidebarHeader, - ProjectActions, SidebarNavigation, ProjectSelectorWithOptions, SidebarFooter, @@ -59,7 +58,7 @@ export function Sidebar() { } = useAppStore(); // Environment variable flags for hiding sidebar items - const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } = + const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; // Get customizable keyboard shortcuts @@ -127,6 +126,9 @@ export function Sidebar() { // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; + // Check if the current project is specifically the one generating spec + const isCurrentProjectGeneratingSpec = + specCreatingForProject !== null && specCreatingForProject === currentProject?.path; // Auto-collapse sidebar on small screens and update Electron window minWidth useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); @@ -232,7 +234,6 @@ export function Sidebar() { hideSpecEditor, hideContext, hideTerminal, - hideAiProfiles, currentProject, projects, projectHistory, @@ -243,6 +244,7 @@ export function Sidebar() { cyclePrevProject, cycleNextProject, unviewedValidationsCount, + isSpecGenerating: isCurrentProjectGeneratingSpec, }); // Register keyboard shortcuts @@ -255,121 +257,122 @@ export function Sidebar() { }; return ( - + + ); } diff --git a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx index 2a7363f25..92b7af992 100644 --- a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -32,14 +32,14 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { 'flex items-center gap-3 titlebar-no-drag cursor-pointer group', !sidebarOpen && 'flex-col gap-1' )} - onClick={() => navigate({ to: '/' })} + onClick={() => navigate({ to: '/dashboard' })} data-testid="logo-button" > - {/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */} + {/* Collapsed logo - only shown when sidebar is closed */}
- {/* Expanded logo - only shown when sidebar is open on large screens */} + {/* Expanded logo - shown when sidebar is open */} {sidebarOpen && ( -
+
- + automaker.
- + v{appVersion} {versionSuffix}
diff --git a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx index 4c09056b9..fc33f6ed1 100644 --- a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx @@ -17,7 +17,9 @@ export function CollapseToggleButton({
); } diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index 147336378..c4d7f3af8 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -1,18 +1,12 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { BoardViewMode } from '@/store/app-store'; +import { ImageIcon, Archive } from 'lucide-react'; interface BoardControlsProps { isMounted: boolean; onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; - kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed'; - onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void; - boardViewMode: BoardViewMode; - onBoardViewModeChange: (mode: BoardViewMode) => void; } export function BoardControls({ @@ -20,61 +14,12 @@ export function BoardControls({ onShowBoardBackground, onShowCompletedModal, completedCount, - kanbanCardDetailLevel, - onDetailLevelChange, - boardViewMode, - onBoardViewModeChange, }: BoardControlsProps) { if (!isMounted) return null; return ( -
- {/* View Mode Toggle - Kanban / Graph */} -
- - - - - -

Kanban Board View

-
-
- - - - - -

Dependency Graph View

-
-
-
- +
{/* Board Background Button */} @@ -115,70 +60,6 @@ export function BoardControls({

Completed Features ({completedCount})

- - {/* Kanban Card Detail Level Toggle */} -
- - - - - -

Minimal - Title & category only

-
-
- - - - - -

Standard - Steps & progress

-
-
- - - - - -

Detailed - Model, tools & tasks

-
-
-
); diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index fb9f38c29..cfaa8a274 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -1,27 +1,38 @@ -import { useState } from 'react'; -import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Plus, Bot, Wand2, Settings2 } from 'lucide-react'; -import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react'; import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; +import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog'; +import { PlanSettingsDialog } from './dialogs/plan-settings-dialog'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { BoardSearchBar } from './board-search-bar'; +import { BoardControls } from './board-controls'; interface BoardHeaderProps { - projectName: string; + projectPath: string; maxConcurrency: number; runningAgentsCount: number; onConcurrencyChange: (value: number) => void; isAutoModeRunning: boolean; onAutoModeToggle: (enabled: boolean) => void; - onAddFeature: () => void; onOpenPlanDialog: () => void; - addFeatureShortcut: KeyboardShortcut; isMounted: boolean; + // Search bar props + searchQuery: string; + onSearchChange: (query: string) => void; + isCreatingSpec: boolean; + creatingSpecProjectPath?: string; + // Board controls props + onShowBoardBackground: () => void; + onShowCompletedModal: () => void; + completedCount: number; } // Shared styles for header control containers @@ -29,24 +40,64 @@ const controlContainerClass = 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border'; export function BoardHeader({ - projectName, + projectPath, maxConcurrency, runningAgentsCount, onConcurrencyChange, isAutoModeRunning, onAutoModeToggle, - onAddFeature, onOpenPlanDialog, - addFeatureShortcut, isMounted, + searchQuery, + onSearchChange, + isCreatingSpec, + creatingSpecProjectPath, + onShowBoardBackground, + onShowCompletedModal, + completedCount, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); + const [showWorktreeSettings, setShowWorktreeSettings] = useState(false); + const [showPlanSettings, setShowPlanSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode); const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode); + const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch); + const setPlanUseSelectedWorktreeBranch = useAppStore( + (state) => state.setPlanUseSelectedWorktreeBranch + ); + const addFeatureUseSelectedWorktreeBranch = useAppStore( + (state) => state.addFeatureUseSelectedWorktreeBranch + ); + const setAddFeatureUseSelectedWorktreeBranch = useAppStore( + (state) => state.setAddFeatureUseSelectedWorktreeBranch + ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + // Worktree panel visibility (per-project) + const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); + const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible); + const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true; + + const handleWorktreePanelToggle = useCallback( + async (visible: boolean) => { + // Update local store + setWorktreePanelVisible(projectPath, visible); + + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(projectPath, { + worktreePanelVisible: visible, + }); + } catch (error) { + console.error('Failed to persist worktree panel visibility:', error); + } + }, + [projectPath, setWorktreePanelVisible] + ); + // Claude usage tracking visibility logic // Hide when using API key (only show for Claude Code CLI users) // Also hide on Windows for now (CLI usage command not supported) @@ -63,37 +114,100 @@ export function BoardHeader({ return (
-
-

Kanban Board

-

{projectName}

+
+ +
{/* Usage Popover - show if either provider is authenticated */} {isMounted && (showClaudeUsage || showCodexUsage) && } - {/* Concurrency Slider - only show after mount to prevent hydration issues */} + {/* Worktrees Toggle - only show after mount to prevent hydration issues */} {isMounted && ( -
- - Agents - onConcurrencyChange(value[0])} - min={1} - max={10} - step={1} - className="w-20" - data-testid="concurrency-slider" +
+ + + - setShowWorktreeSettings(true)} + className="p-1 rounded hover:bg-accent/50 transition-colors" + title="Worktree Settings" + data-testid="worktree-settings-button" > - {runningAgentsCount} / {maxConcurrency} - + +
)} + {/* Worktree Settings Dialog */} + + + {/* Concurrency Control - only show after mount to prevent hydration issues */} + {isMounted && ( + + + + + +
+
+

Max Concurrent Agents

+

+ Controls how many AI agents can run simultaneously. Higher values process more + features in parallel but use more API resources. +

+
+
+ onConcurrencyChange(value[0])} + min={1} + max={10} + step={1} + className="flex-1" + data-testid="concurrency-slider" + /> + + {maxConcurrency} + +
+
+
+
+ )} + {/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {isMounted && (
@@ -125,26 +239,33 @@ export function BoardHeader({ onSkipVerificationChange={setSkipVerificationInAutoMode} /> - - - - - Add Feature - + {/* Plan Button with Settings */} +
+ + +
+ + {/* Plan Settings Dialog */} +
); diff --git a/apps/ui/src/components/views/board-view/components/empty-state-card.tsx b/apps/ui/src/components/views/board-view/components/empty-state-card.tsx new file mode 100644 index 000000000..30ccdefc9 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/empty-state-card.tsx @@ -0,0 +1,120 @@ +import { memo } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; +import { formatShortcut } from '@/store/app-store'; +import { getEmptyStateConfig, type EmptyStateConfig } from '../constants'; +import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react'; + +const ICON_MAP = { + lightbulb: Lightbulb, + play: Play, + clock: Clock, + check: CheckCircle2, + sparkles: Sparkles, +} as const; + +interface EmptyStateCardProps { + columnId: string; + columnTitle?: string; + /** Keyboard shortcut for adding features (from settings) */ + addFeatureShortcut?: string; + /** Whether the column is empty due to active filters */ + isFilteredEmpty?: boolean; + /** Whether we're in read-only mode (hide actions) */ + isReadOnly?: boolean; + /** Called when user clicks "Use AI Suggestions" */ + onAiSuggest?: () => void; + /** Card opacity (matches board settings) */ + opacity?: number; + /** Enable glassmorphism effect */ + glassmorphism?: boolean; + /** Custom config override for pipeline steps */ + customConfig?: Partial; +} + +export const EmptyStateCard = memo(function EmptyStateCard({ + columnId, + addFeatureShortcut, + isFilteredEmpty = false, + isReadOnly = false, + onAiSuggest, + customConfig, +}: EmptyStateCardProps) { + // Get base config and merge with custom overrides + const baseConfig = getEmptyStateConfig(columnId); + const config: EmptyStateConfig = { ...baseConfig, ...customConfig }; + + const IconComponent = ICON_MAP[config.icon]; + const showActions = !isReadOnly && !isFilteredEmpty; + const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions; + + // Action button handler + const handlePrimaryAction = () => { + if (!config.primaryAction) return; + if (config.primaryAction.actionType === 'ai-suggest') { + onAiSuggest?.(); + } + }; + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ {isFilteredEmpty ? 'No Matching Items' : config.title} +

+ + {/* Description */} +

+ {isFilteredEmpty ? 'No features match your current filters.' : config.description} +

+ + {/* Keyboard shortcut hint for backlog */} + {showShortcut && ( +
+ Press + + {formatShortcut(addFeatureShortcut, true)} + + to add +
+ )} + + {/* AI Suggest action for backlog */} + {showActions && config.primaryAction && config.primaryAction.actionType === 'ai-suggest' && ( + + )} + + {/* Filtered empty state hint */} + {isFilteredEmpty && ( +

+ Clear filters to see all items +

+ )} +
+ ); +}); diff --git a/apps/ui/src/components/views/board-view/components/index.ts b/apps/ui/src/components/views/board-view/components/index.ts index 514e407d6..0288031d5 100644 --- a/apps/ui/src/components/views/board-view/components/index.ts +++ b/apps/ui/src/components/views/board-view/components/index.ts @@ -1,3 +1,4 @@ export { KanbanCard } from './kanban-card/kanban-card'; export { KanbanColumn } from './kanban-column'; export { SelectionActionBar } from './selection-action-bar'; +export { EmptyStateCard } from './empty-state-card'; diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 5439b6754..872686529 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import { useEffect, useState } from 'react'; -import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store'; +import { Feature, ThinkingLevel } from '@/store/app-store'; import type { ReasoningEffort } from '@automaker/types'; import { getProviderFromModel } from '@/lib/utils'; import { @@ -68,11 +68,9 @@ export function AgentInfoPanel({ summary, isCurrentAutoTask, }: AgentInfoPanelProps) { - const { kanbanCardDetailLevel } = useAppStore(); const [agentInfo, setAgentInfo] = useState(null); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); - - const showAgentInfo = kanbanCardDetailLevel === 'detailed'; + const [isTodosExpanded, setIsTodosExpanded] = useState(false); useEffect(() => { const loadContext = async () => { @@ -123,7 +121,7 @@ export function AgentInfoPanel({ } }, [feature.id, feature.status, contextContent, isCurrentAutoTask]); // Model/Preset Info for Backlog Cards - if (showAgentInfo && feature.status === 'backlog') { + if (feature.status === 'backlog') { const provider = getProviderFromModel(feature.model); const isCodex = provider === 'codex'; const isClaude = provider === 'claude'; @@ -160,7 +158,7 @@ export function AgentInfoPanel({ } // Agent Info Panel for non-backlog cards - if (showAgentInfo && feature.status !== 'backlog' && agentInfo) { + if (feature.status !== 'backlog' && agentInfo) { return ( <>
@@ -200,32 +198,47 @@ export function AgentInfoPanel({ {agentInfo.todos.length} tasks
-
- {agentInfo.todos.slice(0, 3).map((todo, idx) => ( -
- {todo.status === 'completed' ? ( - - ) : todo.status === 'in_progress' ? ( - - ) : ( - - )} - + {(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map( + (todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + )} - > - {todo.content} - -
- ))} + + {todo.content} + +
+ ) + )} {agentInfo.todos.length > 3 && ( -

- +{agentInfo.todos.length - 3} more -

+ )}
@@ -255,7 +268,11 @@ export function AgentInfoPanel({
-

+

e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > {feature.summary || summary || agentInfo.summary}

@@ -292,58 +309,15 @@ export function AgentInfoPanel({ ); } - // Show just the todo list for non-backlog features when showAgentInfo is false - // This ensures users always see what the agent is working on - if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) { - return ( -
-
- - - {agentInfo.todos.filter((t) => t.status === 'completed').length}/ - {agentInfo.todos.length} tasks - -
-
- {agentInfo.todos.map((todo, idx) => ( -
- {todo.status === 'completed' ? ( - - ) : todo.status === 'in_progress' ? ( - - ) : ( - - )} - - {todo.content} - -
- ))} -
-
- ); - } - - // Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet) + // Always render SummaryDialog (even if no agentInfo yet) // This ensures the dialog can be opened from the expand button return ( - <> - {showAgentInfo && ( - - )} - + ); } diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx index b469da8f0..11e986634 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -31,8 +31,11 @@ export function SummaryDialog({ return ( e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} > diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 4e08cfba7..4a1b62dd7 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -10,6 +10,8 @@ interface KanbanColumnProps { count: number; children: ReactNode; headerAction?: ReactNode; + /** Floating action button at the bottom of the column */ + footerAction?: ReactNode; opacity?: number; showBorder?: boolean; hideScrollbar?: boolean; @@ -24,6 +26,7 @@ export const KanbanColumn = memo(function KanbanColumn({ count, children, headerAction, + footerAction, opacity = 100, showBorder = true, hideScrollbar = false, @@ -79,12 +82,21 @@ export const KanbanColumn = memo(function KanbanColumn({ hideScrollbar && '[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]', // Smooth scrolling - 'scroll-smooth' + 'scroll-smooth', + // Add padding at bottom if there's a footer action + footerAction && 'pb-14' )} > {children}
+ {/* Floating Footer Action */} + {footerAction && ( +
+ {footerAction} +
+ )} + {/* Drop zone indicator when dragging over */} {isOver && (
diff --git a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx index 7f4a553af..7938d05e0 100644 --- a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx +++ b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx @@ -1,11 +1,21 @@ +import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Pencil, X, CheckSquare } from 'lucide-react'; +import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; interface SelectionActionBarProps { selectedCount: number; totalCount: number; onEdit: () => void; + onDelete: () => void; onClear: () => void; onSelectAll: () => void; } @@ -14,65 +24,126 @@ export function SelectionActionBar({ selectedCount, totalCount, onEdit, + onDelete, onClear, onSelectAll, }: SelectionActionBarProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + if (selectedCount === 0) return null; const allSelected = selectedCount === totalCount; + const handleDeleteClick = () => { + setShowDeleteDialog(true); + }; + + const handleConfirmDelete = () => { + setShowDeleteDialog(false); + onDelete(); + }; + return ( -
- - {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected - + <> +
+ + {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected + -
+
-
- +
+ - {!allSelected && ( - )} - + {!allSelected && ( + + )} + + +
-
+ + {/* Delete Confirmation Dialog */} + + + + + + Delete Selected Features? + + + Are you sure you want to permanently delete {selectedCount} feature + {selectedCount !== 1 ? 's' : ''}? + + This action cannot be undone. + + + + + + + + + + ); } diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index 9302ea01d..fda19ebfb 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -3,6 +3,69 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types export type ColumnId = Feature['status']; +/** + * Empty state configuration for each column type + */ +export interface EmptyStateConfig { + title: string; + description: string; + icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles'; + shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A') + shortcutHint?: string; // Human-readable shortcut hint + primaryAction?: { + label: string; + actionType: 'ai-suggest' | 'none'; + }; +} + +/** + * Default empty state configurations per column type + */ +export const EMPTY_STATE_CONFIGS: Record = { + backlog: { + title: 'Ready for Ideas', + description: + 'Add your first feature idea to get started using the button below, or let AI help generate ideas.', + icon: 'lightbulb', + shortcutHint: 'Press', + primaryAction: { + label: 'Use AI Suggestions', + actionType: 'none', + }, + }, + in_progress: { + title: 'Nothing in Progress', + description: 'Drag a feature from the backlog here or click implement to start working on it.', + icon: 'play', + }, + waiting_approval: { + title: 'No Items Awaiting Approval', + description: 'Features will appear here after implementation is complete and need your review.', + icon: 'clock', + }, + verified: { + title: 'No Verified Features', + description: 'Approved features will appear here. They can then be completed and archived.', + icon: 'check', + }, + // Pipeline step default configuration + pipeline_default: { + title: 'Pipeline Step Empty', + description: 'Features will flow through this step during the automated pipeline process.', + icon: 'sparkles', + }, +}; + +/** + * Get empty state config for a column, with fallback for pipeline columns + */ +export function getEmptyStateConfig(columnId: string): EmptyStateConfig { + if (columnId.startsWith('pipeline_')) { + return EMPTY_STATE_CONFIGS.pipeline_default; + } + return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.default; +} + export interface Column { id: FeatureStatusWithPipeline; title: string; diff --git a/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx new file mode 100644 index 000000000..c2eec445d --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx @@ -0,0 +1,254 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import type { PipelineStep } from '@automaker/types'; +import { cn } from '@/lib/utils'; +import { STEP_TEMPLATES } from './pipeline-step-templates'; + +// Color options for pipeline columns +const COLOR_OPTIONS = [ + { value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' }, + { value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' }, + { value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' }, + { value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' }, + { value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' }, + { value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' }, + { value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' }, + { value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' }, + { value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' }, +]; + +interface AddEditPipelineStepDialogProps { + open: boolean; + onClose: () => void; + onSave: (step: Omit & { id?: string }) => void; + existingStep?: PipelineStep | null; + defaultOrder: number; +} + +export function AddEditPipelineStepDialog({ + open, + onClose, + onSave, + existingStep, + defaultOrder, +}: AddEditPipelineStepDialogProps) { + const isEditing = !!existingStep; + const fileInputRef = useRef(null); + + const [name, setName] = useState(''); + const [instructions, setInstructions] = useState(''); + const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Reset form when dialog opens/closes or existingStep changes + useEffect(() => { + if (open) { + if (existingStep) { + setName(existingStep.name); + setInstructions(existingStep.instructions); + setColorClass(existingStep.colorClass); + setSelectedTemplate(null); + } else { + setName(''); + setInstructions(''); + setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value); + setSelectedTemplate(null); + } + } + }, [open, existingStep, defaultOrder]); + + const handleTemplateClick = (templateId: string) => { + const template = STEP_TEMPLATES.find((t) => t.id === templateId); + if (template) { + setName(template.name); + setInstructions(template.instructions); + setColorClass(template.colorClass); + setSelectedTemplate(templateId); + toast.success(`Loaded "${template.name}" template`); + } + }; + + const handleFileUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const content = await file.text(); + setInstructions(content); + toast.success('Instructions loaded from file'); + } catch { + toast.error('Failed to load file'); + } + + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSave = () => { + if (!name.trim()) { + toast.error('Step name is required'); + return; + } + + if (!instructions.trim()) { + toast.error('Step instructions are required'); + return; + } + + onSave({ + id: existingStep?.id, + name: name.trim(), + instructions: instructions.trim(), + colorClass, + order: existingStep?.order ?? defaultOrder, + }); + + onClose(); + }; + + return ( + !isOpen && onClose()}> + + {/* Hidden file input for loading instructions from .md files */} + + + + {isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'} + + {isEditing + ? 'Modify the step configuration below.' + : 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'} + + + +
+ {/* Template Quick Start - Only show for new steps */} + {!isEditing && ( +
+ +
+ {STEP_TEMPLATES.map((template) => ( + + ))} +
+

+ Click a template to pre-fill the form, then customize as needed. +

+
+ )} + + {/* Divider */} + {!isEditing &&
} + + {/* Step Name */} +
+ + setName(e.target.value)} + autoFocus={isEditing} + /> +
+ + {/* Color Selection */} +
+ +
+ {COLOR_OPTIONS.map((color) => ( +
+
+ + {/* Agent Instructions */} +
+
+ + +
+