From 55603cb5c7fd57ef7d424714e9da55efac13e328 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 01:36:15 +0100 Subject: [PATCH 01/47] feat: add GPT-5.2 model support and refresh profiles functionality - Introduced the GPT-5.2 model with advanced coding capabilities across various components. - Added a new button in ProfilesView to refresh default profiles, enhancing user experience. - Updated CodexSetupStep to clarify authentication requirements and added commands for verifying login status. - Enhanced utility functions to recognize the new GPT-5.2 model in the application. --- apps/app/src/components/views/board-view.tsx | 7 ++ .../src/components/views/profiles-view.tsx | 39 ++++++++--- .../setup-view/steps/codex-setup-step.tsx | 29 ++++++-- apps/app/src/lib/utils.ts | 2 + apps/app/src/store/app-store.ts | 20 ++++++ apps/server/src/routes/models.ts | 9 +++ apps/server/src/routes/setup.ts | 45 ++++-------- apps/server/src/services/auto-mode-service.ts | 70 +++++++++++++++++-- 8 files changed, 170 insertions(+), 51 deletions(-) diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 23d332a9..8aa70362 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -146,6 +146,13 @@ const CLAUDE_MODELS: ModelOption[] = [ ]; const CODEX_MODELS: ModelOption[] = [ + { + id: "gpt-5.2", + label: "GPT-5.2", + description: "Latest OpenAI model with advanced coding capabilities.", + badge: "Latest", + provider: "codex", + }, { id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max", diff --git a/apps/app/src/components/views/profiles-view.tsx b/apps/app/src/components/views/profiles-view.tsx index 30316518..43530d55 100644 --- a/apps/app/src/components/views/profiles-view.tsx +++ b/apps/app/src/components/views/profiles-view.tsx @@ -41,6 +41,7 @@ import { GripVertical, Lock, Check, + RefreshCw, } from "lucide-react"; import { toast } from "sonner"; import { @@ -89,6 +90,7 @@ const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [ ]; const CODEX_MODELS: { id: AgentModel; label: string }[] = [ + { id: "gpt-5.2", label: "GPT-5.2" }, { id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, { id: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, { id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" }, @@ -461,6 +463,7 @@ export function ProfilesView() { updateAIProfile, removeAIProfile, reorderAIProfiles, + resetAIProfiles, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); @@ -529,6 +532,13 @@ export function ProfilesView() { }); }; + const handleResetProfiles = () => { + resetAIProfiles(); + toast.success("Profiles refreshed", { + description: "Default profiles have been updated to the latest version", + }); + }; + // Build keyboard shortcuts for profiles view const profilesShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcutsList: KeyboardShortcut[] = []; @@ -568,15 +578,26 @@ export function ProfilesView() {

- setShowAddDialog(true)} - hotkey={shortcuts.addProfile} - hotkeyActive={false} - data-testid="add-profile-button" - > - - New Profile - +
+ + setShowAddDialog(true)} + hotkey={shortcuts.addProfile} + hotkeyActive={false} + data-testid="add-profile-button" + > + + New Profile + +
diff --git a/apps/app/src/components/views/setup-view/steps/codex-setup-step.tsx b/apps/app/src/components/views/setup-view/steps/codex-setup-step.tsx index 13335e02..a5f18ce4 100644 --- a/apps/app/src/components/views/setup-view/steps/codex-setup-step.tsx +++ b/apps/app/src/components/views/setup-view/steps/codex-setup-step.tsx @@ -282,21 +282,21 @@ export function CodexSetupStep({ Authentication - Codex requires an OpenAI API key + Codex requires authentication via ChatGPT account or API key {codexCliStatus?.installed && (
-
-

- Authenticate via CLI +

+

+ Authenticate via CLI (Recommended)

-

- Run this command in your terminal: +

+ Run the following command in your terminal to login with your ChatGPT account:

-
+
codex auth login @@ -308,6 +308,21 @@ export function CodexSetupStep({
+

+ After logging in, you can verify your authentication status: +

+
+ + codex login status + + +
diff --git a/apps/app/src/lib/utils.ts b/apps/app/src/lib/utils.ts index 9fa2f503..8d7b6386 100644 --- a/apps/app/src/lib/utils.ts +++ b/apps/app/src/lib/utils.ts @@ -12,6 +12,7 @@ export function cn(...inputs: ClassValue[]) { export function isCodexModel(model?: AgentModel | string): boolean { if (!model) return false; const codexModels: string[] = [ + "gpt-5.2", "gpt-5.1-codex-max", "gpt-5.1-codex", "gpt-5.1-codex-mini", @@ -36,6 +37,7 @@ export function getModelDisplayName(model: AgentModel | string): string { haiku: "Claude Haiku", sonnet: "Claude Sonnet", opus: "Claude Opus", + "gpt-5.2": "GPT-5.2", "gpt-5.1-codex-max": "GPT-5.1 Codex Max", "gpt-5.1-codex": "GPT-5.1 Codex", "gpt-5.1-codex-mini": "GPT-5.1 Codex Mini", diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 81d50617..2136e4a6 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -203,6 +203,7 @@ export interface FeatureImagePath { export type ClaudeModel = "opus" | "sonnet" | "haiku"; // OpenAI/Codex models export type OpenAIModel = + | "gpt-5.2" | "gpt-5.1-codex-max" | "gpt-5.1-codex" | "gpt-5.1-codex-mini" @@ -445,6 +446,7 @@ export interface AppActions { updateAIProfile: (id: string, updates: Partial) => void; removeAIProfile: (id: string) => void; reorderAIProfiles: (oldIndex: number, newIndex: number) => void; + resetAIProfiles: () => void; // Project Analysis actions setProjectAnalysis: (analysis: ProjectAnalysis | null) => void; @@ -491,6 +493,16 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ isBuiltIn: true, icon: "Zap", }, + { + id: "profile-gpt52", + name: "GPT-5.2", + description: "GPT-5.2 - Latest OpenAI model for advanced coding tasks.", + model: "gpt-5.2", + thinkingLevel: "none", + provider: "codex", + isBuiltIn: true, + icon: "Sparkles", + }, { id: "profile-codex-power", name: "Codex Power", @@ -1106,6 +1118,14 @@ export const useAppStore = create()( set({ aiProfiles: profiles }); }, + resetAIProfiles: () => { + // Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults + const currentProfiles = get().aiProfiles; + const userProfiles = currentProfiles.filter((p) => !p.isBuiltIn); + const mergedProfiles = [...DEFAULT_AI_PROFILES, ...userProfiles]; + set({ aiProfiles: mergedProfiles }); + }, + // Project Analysis actions setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }), setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }), diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts index 529c50a6..5856fac5 100644 --- a/apps/server/src/routes/models.ts +++ b/apps/server/src/routes/models.ts @@ -90,6 +90,15 @@ export function createModelsRoutes(): Router { supportsVision: true, supportsTools: false, }, + { + id: "gpt-5.2", + name: "GPT-5.2 (Codex)", + provider: "openai", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, ]; res.json({ success: true, models }); diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index a1b5b38b..6403fbfa 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -249,56 +249,41 @@ export function createSetupRoutes(): Router { const { stdout: versionOut } = await execAsync("codex --version"); version = versionOut.trim(); } catch { - // Version command might not be available + version = "unknown"; } } catch { // Not found } // Check for OpenAI/Codex authentication + // Simplified: only check via CLI command, no file parsing let auth = { authenticated: false, method: "none" as string, - hasAuthFile: false, hasEnvKey: !!process.env.OPENAI_API_KEY, hasStoredApiKey: !!apiKeys.openai, - hasEnvApiKey: !!process.env.OPENAI_API_KEY, - // Additional fields for subscription/account detection - hasSubscription: false, - cliLoggedIn: false, }; - // Check for OpenAI CLI auth file (~/.codex/auth.json or similar) - const codexAuthPaths = [ - path.join(os.homedir(), ".codex", "auth.json"), - path.join(os.homedir(), ".openai", "credentials"), - path.join(os.homedir(), ".config", "openai", "credentials.json"), - ]; - - for (const authPath of codexAuthPaths) { + // Try to verify authentication using codex CLI command if CLI is installed + if (installed && cliPath) { try { - const authContent = await fs.readFile(authPath, "utf-8"); - const authData = JSON.parse(authContent); - auth.hasAuthFile = true; + const { stdout: statusOutput } = await execAsync(`"${cliPath}" login status 2>&1`, { + timeout: 5000, + }); - // Check for subscription/tokens - if (authData.subscription || authData.plan || authData.account_type) { - auth.hasSubscription = true; + // Check if the output indicates logged in status + if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) { auth.authenticated = true; - auth.method = "subscription"; // Codex subscription (Plus/Team) - } else if (authData.access_token || authData.api_key) { - auth.cliLoggedIn = true; - auth.authenticated = true; - auth.method = "cli_verified"; // CLI logged in with account + auth.method = "cli_verified"; // CLI verified via login status command } - break; - } catch { - // Auth file not found at this path + } catch (error) { + // CLI check failed - user needs to login manually + console.log("[Setup] Codex login status check failed:", error); } } - // Environment variable has highest priority - if (auth.hasEnvApiKey) { + // Environment variable override + if (process.env.OPENAI_API_KEY) { auth.authenticated = true; auth.method = "env"; // OPENAI_API_KEY environment variable } diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8015de91..4c44cd6e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -18,6 +18,13 @@ import type { EventEmitter, EventType } from "../lib/events.js"; const execAsync = promisify(exec); +// Model name mappings for Claude (matching electron version) +const MODEL_MAP: Record = { + haiku: "claude-haiku-4-5", + sonnet: "claude-sonnet-4-20250514", + opus: "claude-opus-4-5-20251101", +}; + interface Feature { id: string; title: string; @@ -25,6 +32,37 @@ interface Feature { status: string; priority?: number; spec?: string; + model?: string; // Model to use for this feature +} + +/** + * Get model string from feature's model property + * Supports model keys like "opus", "sonnet", "haiku" or full model strings + * Also supports OpenAI/Codex models like "gpt-5.2", "gpt-5.1-codex", etc. + */ +function getModelString(feature: Feature): string { + const modelKey = feature.model || "opus"; // Default to opus + + // Check if it's an OpenAI/Codex model (starts with "gpt-" or "o" for O-series) + if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) { + console.log(`[AutoMode] Using OpenAI/Codex model from feature ${feature.id}: ${modelKey} (passing through)`); + return modelKey; + } + + // If it's already a full Claude model string (contains "claude-"), use it directly + if (modelKey.includes("claude-")) { + console.log(`[AutoMode] Using Claude model from feature ${feature.id}: ${modelKey} (full model string)`); + return modelKey; + } + + // Otherwise, look it up in the Claude model map + const modelString = MODEL_MAP[modelKey] || MODEL_MAP.opus; + if (modelString !== MODEL_MAP.opus || modelKey === "opus") { + console.log(`[AutoMode] Resolved Claude model for feature ${feature.id}: "${modelKey}" -> "${modelString}"`); + } else { + console.warn(`[AutoMode] Unknown model key "${modelKey}" for feature ${feature.id}, defaulting to "${modelString}"`); + } + return modelString; } interface RunningFeature { @@ -199,8 +237,12 @@ export class AutoModeService { // Build the prompt const prompt = this.buildFeaturePrompt(feature); - // Run the agent - await this.runAgent(workDir, featureId, prompt, abortController); + // Get model from feature + const model = getModelString(feature); + console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`); + + // Run the agent with the feature's model + await this.runAgent(workDir, featureId, prompt, abortController, undefined, model); // Mark as waiting_approval for user review await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); @@ -330,7 +372,12 @@ export class AutoModeService { }); try { - await this.runAgent(workDir, featureId, prompt, abortController, imagePaths); + // Load feature to get its model + const feature = await this.loadFeature(projectPath, featureId); + const model = feature ? getModelString(feature) : MODEL_MAP.opus; + console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); + + await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -709,10 +756,23 @@ When done, summarize what you implemented and any notes for the developer.`; featureId: string, prompt: string, abortController: AbortController, - imagePaths?: string[] + imagePaths?: string[], + model?: string ): Promise { + const finalModel = model || MODEL_MAP.opus; + console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`); + + // Check if this is an OpenAI/Codex model - Claude Agent SDK doesn't support these + if (finalModel.startsWith("gpt-") || finalModel.startsWith("o")) { + const errorMessage = `OpenAI/Codex models (like "${finalModel}") are not yet supported in server mode. ` + + `Please use a Claude model (opus, sonnet, or haiku) instead. ` + + `OpenAI/Codex models are only supported in the Electron app.`; + console.error(`[AutoMode] ${errorMessage}`); + throw new Error(errorMessage); + } + const options: Options = { - model: "claude-opus-4-5-20251101", + model: finalModel, maxTurns: 50, cwd: workDir, allowedTools: [ From a65b16cbaebf8c6745e6fc7faa3498ff701d2c45 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 03:45:41 +0100 Subject: [PATCH 02/47] feat: implement modular provider architecture with Codex CLI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a flexible provider pattern that supports both Claude Agent SDK and OpenAI Codex CLI, enabling future expansion to other AI providers (Cursor, OpenCode, etc.) with minimal changes. ## Architecture Changes ### New Provider System - Created provider abstraction layer with BaseProvider interface - Model-based routing: model prefix determines provider - `gpt-*`, `o*` → CodexProvider (subprocess CLI) - `claude-*`, `opus/sonnet/haiku` → ClaudeProvider (SDK) - Providers implement common ExecuteOptions interface ### New Files Created - `providers/types.ts` - Shared interfaces (ExecuteOptions, ProviderMessage, etc.) - `providers/base-provider.ts` - Abstract base class - `providers/claude-provider.ts` - Claude Agent SDK wrapper - `providers/codex-provider.ts` - Codex CLI subprocess executor - `providers/codex-cli-detector.ts` - Installation & auth detection - `providers/codex-config-manager.ts` - TOML config management - `providers/provider-factory.ts` - Model-based provider routing - `lib/subprocess-manager.ts` - Reusable subprocess utilities ## Features Implemented ### Codex CLI Integration - Spawns Codex CLI as subprocess with JSONL output - Converts Codex events to Claude SDK-compatible format - Supports both `codex login` and OPENAI_API_KEY auth methods - Handles: reasoning, messages, commands, todos, file changes - Extracts text from content blocks for non-vision CLI ### Conversation History - Added conversationHistory support to ExecuteOptions - ClaudeProvider: yields previous messages to SDK - CodexProvider: prepends history as text context - Follow-up prompts maintain full conversation context ### Image Upload Support - Images embedded as base64 for vision models - Image paths appended to prompt text for Read tool access - Auto-mode: copies images to feature folder - Follow-up: combines original + new images - Updates feature.json with image metadata ### Session Model Persistence - Added `model` field to Session and SessionMetadata - Sessions remember model preference across interactions - API endpoints accept model parameter - Auto-mode respects feature's model setting ## Modified Files ### Services - `agent-service.ts`: - Added conversation history building - Uses ProviderFactory instead of direct SDK calls - Appends image paths to prompts - Added model parameter and persistence - `auto-mode-service.ts`: - Removed OpenAI model block restriction - Uses ProviderFactory for all models - Added image support in buildFeaturePrompt - Follow-up: loads context, copies images, updates feature.json - Returns to waiting_approval after follow-up ### Routes - `agent.ts`: Added model parameter to /send endpoint - `sessions.ts`: Added model field to create/update - `models.ts`: Added Codex models (gpt-5.2, gpt-5.1-codex*) ### Configuration - `.env.example`: Added OPENAI_API_KEY and CODEX_CLI_PATH - `.gitignore`: Added provider-specific ignores ## Bug Fixes - Fixed image path resolution (relative → absolute) - Fixed Codex empty prompt when images attached - Fixed follow-up status management (in_progress → waiting_approval) - Fixed follow-up images not appearing in prompt text - Removed OpenAI model restrictions in auto-mode ## Testing Notes - Codex CLI authentication verified with both methods - Image uploads work for both Claude (vision) and Codex (Read tool) - Follow-up prompts maintain full context - Conversation history persists across turns - Model switching works per-session šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 2 + apps/server/.env.example | 6 +- apps/server/src/lib/subprocess-manager.ts | 202 +++++++ apps/server/src/providers/base-provider.ts | 96 +++ apps/server/src/providers/claude-provider.ts | 202 +++++++ .../src/providers/codex-cli-detector.ts | 408 +++++++++++++ .../src/providers/codex-config-manager.ts | 355 +++++++++++ apps/server/src/providers/codex-provider.ts | 550 ++++++++++++++++++ apps/server/src/providers/provider-factory.ts | 126 ++++ apps/server/src/providers/types.ts | 103 ++++ apps/server/src/routes/agent.ts | 25 +- apps/server/src/routes/models.ts | 37 +- apps/server/src/routes/sessions.ts | 11 +- apps/server/src/services/agent-service.ts | 98 +++- apps/server/src/services/auto-mode-service.ts | 272 +++++++-- 15 files changed, 2404 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/lib/subprocess-manager.ts create mode 100644 apps/server/src/providers/base-provider.ts create mode 100644 apps/server/src/providers/claude-provider.ts create mode 100644 apps/server/src/providers/codex-cli-detector.ts create mode 100644 apps/server/src/providers/codex-config-manager.ts create mode 100644 apps/server/src/providers/codex-provider.ts create mode 100644 apps/server/src/providers/provider-factory.ts create mode 100644 apps/server/src/providers/types.ts diff --git a/.gitignore b/.gitignore index 59cf700e..fc3d5652 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ node_modules .automaker/ /.automaker/* /.automaker/ + +/old \ No newline at end of file diff --git a/apps/server/.env.example b/apps/server/.env.example index 6ce580b1..e9cf96dd 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -38,8 +38,12 @@ DATA_DIR=./data # OPTIONAL - Additional AI Providers # ============================================ -# OpenAI API key (for Codex CLI support) +# OpenAI API key for GPT/Codex models (gpt-5.2, gpt-5.1-codex, etc.) +# Codex CLI must be installed: npm install -g @openai/codex@latest OPENAI_API_KEY= +# Optional: Override Codex CLI path (auto-detected by default) +# CODEX_CLI_PATH=/usr/local/bin/codex + # Google API key (for future Gemini support) GOOGLE_API_KEY= diff --git a/apps/server/src/lib/subprocess-manager.ts b/apps/server/src/lib/subprocess-manager.ts new file mode 100644 index 00000000..ddfc7485 --- /dev/null +++ b/apps/server/src/lib/subprocess-manager.ts @@ -0,0 +1,202 @@ +/** + * Subprocess management utilities for CLI providers + */ + +import { spawn, type ChildProcess } from "child_process"; +import readline from "readline"; + +export interface SubprocessOptions { + command: string; + args: string[]; + cwd: string; + env?: Record; + abortController?: AbortController; + timeout?: number; // Milliseconds of no output before timeout +} + +export interface SubprocessResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +/** + * Spawns a subprocess and streams JSONL output line-by-line + */ +export async function* spawnJSONLProcess( + options: SubprocessOptions +): AsyncGenerator { + const { command, args, cwd, env, abortController, timeout = 30000 } = options; + + const processEnv = { + ...process.env, + ...env, + }; + + const childProcess: ChildProcess = spawn(command, args, { + cwd, + env: processEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderrOutput = ""; + let lastOutputTime = Date.now(); + let timeoutHandle: NodeJS.Timeout | null = null; + + // Collect stderr for error reporting + if (childProcess.stderr) { + childProcess.stderr.on("data", (data: Buffer) => { + const text = data.toString(); + stderrOutput += text; + console.error(`[SubprocessManager] stderr: ${text}`); + }); + } + + // Setup timeout detection + const resetTimeout = () => { + lastOutputTime = Date.now(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + timeoutHandle = setTimeout(() => { + const elapsed = Date.now() - lastOutputTime; + if (elapsed >= timeout) { + console.error( + `[SubprocessManager] Process timeout: no output for ${timeout}ms` + ); + childProcess.kill("SIGTERM"); + } + }, timeout); + }; + + resetTimeout(); + + // Setup abort handling + if (abortController) { + abortController.signal.addEventListener("abort", () => { + console.log("[SubprocessManager] Abort signal received, killing process"); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + childProcess.kill("SIGTERM"); + }); + } + + // Parse stdout as JSONL (one JSON object per line) + if (childProcess.stdout) { + const rl = readline.createInterface({ + input: childProcess.stdout, + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + resetTimeout(); + + if (!line.trim()) continue; + + try { + const parsed = JSON.parse(line); + yield parsed; + } catch (parseError) { + console.error( + `[SubprocessManager] Failed to parse JSONL line: ${line}`, + parseError + ); + // Yield error but continue processing + yield { + type: "error", + error: `Failed to parse output: ${line}`, + }; + } + } + } catch (error) { + console.error("[SubprocessManager] Error reading stdout:", error); + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } + + // Wait for process to exit + const exitCode = await new Promise((resolve) => { + childProcess.on("exit", (code) => { + resolve(code); + }); + + childProcess.on("error", (error) => { + console.error("[SubprocessManager] Process error:", error); + resolve(null); + }); + }); + + // Handle non-zero exit codes + if (exitCode !== 0 && exitCode !== null) { + const errorMessage = stderrOutput || `Process exited with code ${exitCode}`; + console.error(`[SubprocessManager] Process failed: ${errorMessage}`); + yield { + type: "error", + error: errorMessage, + }; + } + + // Process completed successfully + if (exitCode === 0 && !stderrOutput) { + // Success - no logging needed + } +} + +/** + * Spawns a subprocess and collects all output + */ +export async function spawnProcess( + options: SubprocessOptions +): Promise { + const { command, args, cwd, env, abortController } = options; + + const processEnv = { + ...process.env, + ...env, + }; + + return new Promise((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd, + env: processEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + if (childProcess.stdout) { + childProcess.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + } + + if (childProcess.stderr) { + childProcess.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + } + + // Setup abort handling + if (abortController) { + abortController.signal.addEventListener("abort", () => { + childProcess.kill("SIGTERM"); + reject(new Error("Process aborted")); + }); + } + + childProcess.on("exit", (code) => { + resolve({ stdout, stderr, exitCode: code }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); +} diff --git a/apps/server/src/providers/base-provider.ts b/apps/server/src/providers/base-provider.ts new file mode 100644 index 00000000..4b483ed7 --- /dev/null +++ b/apps/server/src/providers/base-provider.ts @@ -0,0 +1,96 @@ +/** + * Abstract base class for AI model providers + */ + +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ValidationResult, + ModelDefinition, +} from "./types.js"; + +/** + * Base provider class that all provider implementations must extend + */ +export abstract class BaseProvider { + protected config: ProviderConfig; + protected name: string; + + constructor(config: ProviderConfig = {}) { + this.config = config; + this.name = this.getName(); + } + + /** + * Get the provider name (e.g., "claude", "codex", "cursor") + */ + abstract getName(): string; + + /** + * Execute a query and stream responses + * @param options Execution options + * @returns AsyncGenerator yielding provider messages + */ + abstract executeQuery( + options: ExecuteOptions + ): AsyncGenerator; + + /** + * Detect if the provider is installed and configured + * @returns Installation status + */ + abstract detectInstallation(): Promise; + + /** + * Get available models for this provider + * @returns Array of model definitions + */ + abstract getAvailableModels(): ModelDefinition[]; + + /** + * Validate the provider configuration + * @returns Validation result + */ + validateConfig(): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Base validation (can be overridden) + if (!this.config) { + errors.push("Provider config is missing"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Check if the provider supports a specific feature + * @param feature Feature name (e.g., "vision", "tools", "mcp") + * @returns Whether the feature is supported + */ + supportsFeature(feature: string): boolean { + // Default implementation - override in subclasses + const commonFeatures = ["tools", "text"]; + return commonFeatures.includes(feature); + } + + /** + * Get provider configuration + */ + getConfig(): ProviderConfig { + return this.config; + } + + /** + * Update provider configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts new file mode 100644 index 00000000..9384ad34 --- /dev/null +++ b/apps/server/src/providers/claude-provider.ts @@ -0,0 +1,202 @@ +/** + * Claude Provider - Executes queries using Claude Agent SDK + * + * Wraps the @anthropic-ai/claude-agent-sdk for seamless integration + * with the provider architecture. + */ + +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { BaseProvider } from "./base-provider.js"; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from "./types.js"; + +export class ClaudeProvider extends BaseProvider { + getName(): string { + return "claude"; + } + + /** + * Execute a query using Claude Agent SDK + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model, + cwd, + systemPrompt, + maxTurns = 20, + allowedTools, + abortController, + conversationHistory, + } = options; + + // Build Claude SDK options + const sdkOptions: Options = { + model, + systemPrompt, + maxTurns, + cwd, + allowedTools: allowedTools || [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "WebSearch", + "WebFetch", + ], + permissionMode: "acceptEdits", + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + abortController, + }; + + // Build prompt payload with conversation history + let promptPayload: string | AsyncGenerator; + + if (conversationHistory && conversationHistory.length > 0) { + // Multi-turn conversation with history + promptPayload = (async function* () { + // Yield all previous messages first + for (const historyMsg of conversationHistory) { + yield { + type: historyMsg.role, + session_id: "", + message: { + role: historyMsg.role, + content: Array.isArray(historyMsg.content) + ? historyMsg.content + : [{ type: "text", text: historyMsg.content }], + }, + parent_tool_use_id: null, + }; + } + + // Yield current prompt + yield { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: Array.isArray(prompt) + ? prompt + : [{ type: "text", text: prompt }], + }, + parent_tool_use_id: null, + }; + })(); + } else if (Array.isArray(prompt)) { + // Multi-part prompt (with images) - no history + promptPayload = (async function* () { + yield { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: prompt, + }, + parent_tool_use_id: null, + }; + })(); + } else { + // Simple text prompt - no history + promptPayload = prompt; + } + + // Execute via Claude Agent SDK + const stream = query({ prompt: promptPayload, options: sdkOptions }); + + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + yield msg as ProviderMessage; + } + } + + /** + * Detect Claude SDK installation (always available via npm) + */ + async detectInstallation(): Promise { + // Claude SDK is always available since it's a dependency + const hasApiKey = + !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + return { + installed: true, + method: "sdk", + hasApiKey, + authenticated: hasApiKey, + }; + } + + /** + * Get available Claude models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + modelString: "claude-opus-4-5-20251101", + provider: "anthropic", + description: "Most capable Claude model", + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: "premium", + default: true, + }, + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + modelString: "claude-sonnet-4-20250514", + provider: "anthropic", + description: "Balanced performance and cost", + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + { + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + modelString: "claude-3-5-sonnet-20241022", + provider: "anthropic", + description: "Fast and capable", + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + modelString: "claude-3-5-haiku-20241022", + provider: "anthropic", + description: "Fastest Claude model", + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: "basic", + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ["tools", "text", "vision", "thinking"]; + return supportedFeatures.includes(feature); + } +} diff --git a/apps/server/src/providers/codex-cli-detector.ts b/apps/server/src/providers/codex-cli-detector.ts new file mode 100644 index 00000000..21445b46 --- /dev/null +++ b/apps/server/src/providers/codex-cli-detector.ts @@ -0,0 +1,408 @@ +/** + * Codex CLI Detector - Checks if OpenAI Codex CLI is installed + * + * Codex CLI is OpenAI's agent CLI tool that allows users to use + * GPT-5.1/5.2 Codex models for code generation and agentic tasks. + */ + +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import type { InstallationStatus } from "./types.js"; + +export class CodexCliDetector { + /** + * Get the path to Codex config directory + */ + static getConfigDir(): string { + return path.join(os.homedir(), ".codex"); + } + + /** + * Get the path to Codex auth file + */ + static getAuthPath(): string { + return path.join(this.getConfigDir(), "auth.json"); + } + + /** + * Check Codex authentication status + */ + static checkAuth(): { + authenticated: boolean; + method: string; + hasAuthFile?: boolean; + hasEnvKey?: boolean; + authPath?: string; + error?: string; + } { + try { + const authPath = this.getAuthPath(); + const envApiKey = process.env.OPENAI_API_KEY; + + // Try to verify authentication using codex CLI command if available + try { + const detection = this.detectCodexInstallation(); + if (detection.installed && detection.path) { + try { + // Use 2>&1 to capture both stdout and stderr + const statusOutput = execSync( + `"${detection.path}" login status 2>&1`, + { + encoding: "utf-8", + timeout: 5000, + } + ).trim(); + + // Check if the output indicates logged in status + if ( + statusOutput && + (statusOutput.includes("Logged in") || statusOutput.includes("Authenticated")) + ) { + return { + authenticated: true, + method: "cli_verified", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: !!envApiKey, + authPath, + }; + } + } catch (statusError) { + // status command failed, continue with file-based check + } + } + } catch (verifyError) { + // CLI verification failed, continue with file-based check + } + + // Check if auth file exists + if (fs.existsSync(authPath)) { + try { + const content = fs.readFileSync(authPath, "utf-8"); + const auth: any = JSON.parse(content); + + // Check for token object structure + if (auth.token && typeof auth.token === "object") { + const token = auth.token; + if ( + token.Id_token || + token.access_token || + token.refresh_token || + token.id_token + ) { + return { + authenticated: true, + method: "cli_tokens", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } + + // Check for tokens at root level + if ( + auth.access_token || + auth.refresh_token || + auth.Id_token || + auth.id_token + ) { + return { + authenticated: true, + method: "cli_tokens", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + + // Check for API key fields + if (auth.api_key || auth.openai_api_key || auth.apiKey) { + return { + authenticated: true, + method: "auth_file", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } catch (error) { + return { + authenticated: false, + method: "none", + hasAuthFile: false, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } + + // Environment variable override + if (envApiKey) { + return { + authenticated: true, + method: "env", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: true, + authPath, + }; + } + + return { + authenticated: false, + method: "none", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: false, + authPath, + }; + } catch (error) { + return { + authenticated: false, + method: "none", + error: (error as Error).message, + }; + } + } + + /** + * Check if Codex CLI is installed and accessible + */ + static detectCodexInstallation(): InstallationStatus & { + hasApiKey?: boolean; + } { + try { + // Method 1: Check if 'codex' command is in PATH + try { + const codexPath = execSync("which codex 2>/dev/null", { + encoding: "utf-8", + }).trim(); + if (codexPath) { + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "cli", + }; + } + } catch (error) { + // CLI not in PATH, continue checking other methods + } + + // Method 2: Check for npm global installation + try { + const npmListOutput = execSync( + "npm list -g @openai/codex --depth=0 2>/dev/null", + { encoding: "utf-8" } + ); + if (npmListOutput && npmListOutput.includes("@openai/codex")) { + // Get the path from npm bin + const npmBinPath = execSync("npm bin -g", { + encoding: "utf-8", + }).trim(); + const codexPath = path.join(npmBinPath, "codex"); + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "npm", + }; + } + } catch (error) { + // npm global not found + } + + // Method 3: Check for Homebrew installation on macOS + if (process.platform === "darwin") { + try { + const brewList = execSync("brew list --formula 2>/dev/null", { + encoding: "utf-8", + }); + if (brewList.includes("codex")) { + const brewPrefixOutput = execSync("brew --prefix codex 2>/dev/null", { + encoding: "utf-8", + }).trim(); + const codexPath = path.join(brewPrefixOutput, "bin", "codex"); + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "brew", + }; + } + } catch (error) { + // Homebrew not found or codex not installed via brew + } + } + + // Method 4: Check Windows path + if (process.platform === "win32") { + try { + const codexPath = execSync("where codex 2>nul", { + encoding: "utf-8", + }) + .trim() + .split("\n")[0]; + if (codexPath) { + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "cli", + }; + } + } catch (error) { + // Not found on Windows + } + } + + // Method 5: Check common installation paths + const commonPaths = [ + path.join(os.homedir(), ".local", "bin", "codex"), + path.join(os.homedir(), ".npm-global", "bin", "codex"), + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + ]; + + for (const checkPath of commonPaths) { + if (fs.existsSync(checkPath)) { + const version = this.getCodexVersion(checkPath); + return { + installed: true, + path: checkPath, + version: version || undefined, + method: "cli", + }; + } + } + + // Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly) + if (process.env.OPENAI_API_KEY) { + return { + installed: false, + hasApiKey: true, + }; + } + + return { + installed: false, + }; + } catch (error) { + return { + installed: false, + error: (error as Error).message, + }; + } + } + + /** + * Get Codex CLI version from executable path + */ + static getCodexVersion(codexPath: string): string | null { + try { + const version = execSync(`"${codexPath}" --version 2>/dev/null`, { + encoding: "utf-8", + }).trim(); + return version || null; + } catch (error) { + return null; + } + } + + /** + * Get installation info and recommendations + */ + static getInstallationInfo(): { + status: string; + method?: string; + version?: string | null; + path?: string | null; + recommendation: string; + installCommands?: Record; + } { + const detection = this.detectCodexInstallation(); + + if (detection.installed) { + return { + status: "installed", + method: detection.method, + version: detection.version, + path: detection.path, + recommendation: + detection.method === "cli" + ? "Using Codex CLI - ready for GPT-5.1/5.2 Codex models" + : `Using Codex CLI via ${detection.method} - ready for GPT-5.1/5.2 Codex models`, + }; + } + + // Not installed but has API key + if (detection.hasApiKey) { + return { + status: "api_key_only", + method: "api-key-only", + recommendation: + "OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.", + installCommands: this.getInstallCommands(), + }; + } + + return { + status: "not_installed", + recommendation: + "Install OpenAI Codex CLI to use GPT-5.1/5.2 Codex models for agentic tasks", + installCommands: this.getInstallCommands(), + }; + } + + /** + * Get installation commands for different platforms + */ + static getInstallCommands(): Record { + return { + npm: "npm install -g @openai/codex@latest", + macos: "brew install codex", + linux: "npm install -g @openai/codex@latest", + windows: "npm install -g @openai/codex@latest", + }; + } + + /** + * Check if Codex CLI supports a specific model + */ + static isModelSupported(model: string): boolean { + const supportedModels = [ + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1", + "gpt-5.2", + ]; + return supportedModels.includes(model); + } + + /** + * Get default model for Codex CLI + */ + static getDefaultModel(): string { + return "gpt-5.2"; + } + + /** + * Get comprehensive installation info including auth status + */ + static getFullStatus() { + const installation = this.detectCodexInstallation(); + const auth = this.checkAuth(); + const info = this.getInstallationInfo(); + + return { + ...info, + auth, + installation, + }; + } +} diff --git a/apps/server/src/providers/codex-config-manager.ts b/apps/server/src/providers/codex-config-manager.ts new file mode 100644 index 00000000..12c3259b --- /dev/null +++ b/apps/server/src/providers/codex-config-manager.ts @@ -0,0 +1,355 @@ +/** + * Codex TOML Configuration Manager + * + * Manages Codex CLI's TOML configuration file to add/update MCP server settings. + * Codex CLI looks for config at: + * - ~/.codex/config.toml (user-level) + * - .codex/config.toml (project-level, takes precedence) + */ + +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +interface McpServerConfig { + command: string; + args?: string[]; + env?: Record; + startup_timeout_sec?: number; + tool_timeout_sec?: number; + enabled_tools?: string[]; +} + +interface CodexConfig { + experimental_use_rmcp_client?: boolean; + mcp_servers?: Record; + [key: string]: any; +} + +export class CodexConfigManager { + private userConfigPath: string; + private projectConfigPath: string | null = null; + + constructor() { + this.userConfigPath = path.join(os.homedir(), ".codex", "config.toml"); + } + + /** + * Set the project path for project-level config + */ + setProjectPath(projectPath: string): void { + this.projectConfigPath = path.join(projectPath, ".codex", "config.toml"); + } + + /** + * Get the effective config path (project-level if exists, otherwise user-level) + */ + async getConfigPath(): Promise { + if (this.projectConfigPath) { + try { + await fs.access(this.projectConfigPath); + return this.projectConfigPath; + } catch (e) { + // Project config doesn't exist, fall back to user config + } + } + + // Ensure user config directory exists + const userConfigDir = path.dirname(this.userConfigPath); + try { + await fs.mkdir(userConfigDir, { recursive: true }); + } catch (e) { + // Directory might already exist + } + + return this.userConfigPath; + } + + /** + * Read existing TOML config (simple parser for our needs) + */ + async readConfig(configPath: string): Promise { + try { + const content = await fs.readFile(configPath, "utf-8"); + return this.parseToml(content); + } catch (e: any) { + if (e.code === "ENOENT") { + return {}; + } + throw e; + } + } + + /** + * Simple TOML parser for our specific use case + * This is a minimal parser that handles the MCP server config structure + */ + parseToml(content: string): CodexConfig { + const config: CodexConfig = {}; + let currentSection: string | null = null; + let currentSubsection: string | null = null; + + const lines = content.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + // Section header: [section] + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const sectionName = sectionMatch[1]; + const parts = sectionName.split("."); + + if (parts.length === 1) { + currentSection = parts[0]; + currentSubsection = null; + if (!config[currentSection]) { + config[currentSection] = {}; + } + } else if (parts.length === 2) { + currentSection = parts[0]; + currentSubsection = parts[1]; + if (!config[currentSection]) { + config[currentSection] = {}; + } + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + } + continue; + } + + // Key-value pair: key = value + const kvMatch = trimmed.match(/^([^=]+)=(.+)$/); + if (kvMatch) { + const key = kvMatch[1].trim(); + let value: any = kvMatch[2].trim(); + + // Remove quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Parse boolean + if (value === "true") value = true; + else if (value === "false") value = false; + // Parse number + else if (/^-?\d+$/.test(value)) value = parseInt(value, 10); + else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value); + + if (currentSubsection && currentSection) { + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + config[currentSection][currentSubsection][key] = value; + } else if (currentSection) { + if (!config[currentSection]) { + config[currentSection] = {}; + } + config[currentSection][key] = value; + } else { + config[key] = value; + } + } + } + + return config; + } + + /** + * Configure the automaker-tools MCP server + */ + async configureMcpServer( + projectPath: string, + mcpServerScriptPath: string + ): Promise { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + // Read existing config + const config = await this.readConfig(configPath); + + // Ensure mcp_servers section exists + if (!config.mcp_servers) { + config.mcp_servers = {}; + } + + // Configure automaker-tools server + config.mcp_servers["automaker-tools"] = { + command: "node", + args: [mcpServerScriptPath], + env: { + AUTOMAKER_PROJECT_PATH: projectPath, + }, + startup_timeout_sec: 10, + tool_timeout_sec: 60, + enabled_tools: ["UpdateFeatureStatus"], + }; + + // Ensure experimental_use_rmcp_client is enabled (if needed) + if (!config.experimental_use_rmcp_client) { + config.experimental_use_rmcp_client = true; + } + + // Write config back + await this.writeConfig(configPath, config); + + console.log( + `[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}` + ); + return configPath; + } + + /** + * Write config to TOML file + */ + async writeConfig(configPath: string, config: CodexConfig): Promise { + let content = ""; + + // Write top-level keys first (preserve existing non-MCP config) + for (const [key, value] of Object.entries(config)) { + if (key === "mcp_servers" || key === "experimental_use_rmcp_client") { + continue; // Handle these separately + } + if (typeof value !== "object") { + content += `${key} = ${this.formatValue(value)}\n`; + } + } + + // Write experimental flag if enabled + if (config.experimental_use_rmcp_client) { + if (content && !content.endsWith("\n\n")) { + content += "\n"; + } + content += `experimental_use_rmcp_client = true\n`; + } + + // Write mcp_servers section + if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) { + if (content && !content.endsWith("\n\n")) { + content += "\n"; + } + + for (const [serverName, serverConfig] of Object.entries( + config.mcp_servers + )) { + content += `\n[mcp_servers.${serverName}]\n`; + + // Write command first + if (serverConfig.command) { + content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`; + } + + // Write args + if (serverConfig.args && Array.isArray(serverConfig.args)) { + const argsStr = serverConfig.args + .map((a) => `"${this.escapeTomlString(a)}"`) + .join(", "); + content += `args = [${argsStr}]\n`; + } + + // Write timeouts (must be before env subsection) + if (serverConfig.startup_timeout_sec !== undefined) { + content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`; + } + + if (serverConfig.tool_timeout_sec !== undefined) { + content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`; + } + + // Write enabled_tools (must be before env subsection - at server level, not env level) + if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) { + const toolsStr = serverConfig.enabled_tools + .map((t) => `"${this.escapeTomlString(t)}"`) + .join(", "); + content += `enabled_tools = [${toolsStr}]\n`; + } + + // Write env section last (as a separate subsection) + if ( + serverConfig.env && + typeof serverConfig.env === "object" && + Object.keys(serverConfig.env).length > 0 + ) { + content += `\n[mcp_servers.${serverName}.env]\n`; + for (const [envKey, envValue] of Object.entries(serverConfig.env)) { + content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`; + } + } + } + } + + // Ensure directory exists + const configDir = path.dirname(configPath); + await fs.mkdir(configDir, { recursive: true }); + + // Write file + await fs.writeFile(configPath, content, "utf-8"); + } + + /** + * Escape special characters in TOML strings + */ + escapeTomlString(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); + } + + /** + * Format a value for TOML output + */ + formatValue(value: any): string { + if (typeof value === "string") { + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; + } else if (typeof value === "boolean") { + return value.toString(); + } else if (typeof value === "number") { + return value.toString(); + } + return `"${String(value)}"`; + } + + /** + * Remove automaker-tools MCP server configuration + */ + async removeMcpServer(projectPath: string): Promise { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + try { + const config = await this.readConfig(configPath); + + if (config.mcp_servers && config.mcp_servers["automaker-tools"]) { + delete config.mcp_servers["automaker-tools"]; + + // If no more MCP servers, remove the section + if (Object.keys(config.mcp_servers).length === 0) { + delete config.mcp_servers; + } + + await this.writeConfig(configPath, config); + console.log( + `[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}` + ); + } + } catch (e) { + console.error(`[CodexConfigManager] Error removing MCP server config:`, e); + } + } +} + +// Export singleton instance +export const codexConfigManager = new CodexConfigManager(); diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts new file mode 100644 index 00000000..0c608def --- /dev/null +++ b/apps/server/src/providers/codex-provider.ts @@ -0,0 +1,550 @@ +/** + * Codex Provider - Executes queries using OpenAI Codex CLI + * + * Spawns Codex CLI as a subprocess and converts JSONL output to + * Claude SDK-compatible message format for seamless integration. + */ + +import { BaseProvider } from "./base-provider.js"; +import { CodexCliDetector } from "./codex-cli-detector.js"; +import { codexConfigManager } from "./codex-config-manager.js"; +import { spawnJSONLProcess } from "../lib/subprocess-manager.js"; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, +} from "./types.js"; + +// Codex event types +const CODEX_EVENT_TYPES = { + THREAD_STARTED: "thread.started", + THREAD_COMPLETED: "thread.completed", + ITEM_STARTED: "item.started", + ITEM_COMPLETED: "item.completed", + TURN_STARTED: "turn.started", + ERROR: "error", +}; + +interface CodexEvent { + type: string; + data?: any; + item?: any; + thread_id?: string; + message?: string; +} + +export class CodexProvider extends BaseProvider { + getName(): string { + return "codex"; + } + + /** + * Execute a query using Codex CLI + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model = "gpt-5.2", + cwd, + systemPrompt, + mcpServers, + abortController, + conversationHistory, + } = options; + + // Find Codex CLI path + const codexPath = this.findCodexPath(); + if (!codexPath) { + yield { + type: "error", + error: + "Codex CLI not found. Please install it with: npm install -g @openai/codex@latest", + }; + return; + } + + // Configure MCP server if provided + if (mcpServers && mcpServers["automaker-tools"]) { + try { + const mcpServerScriptPath = await this.getMcpServerPath(); + if (mcpServerScriptPath) { + await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath); + } + } catch (error) { + console.error("[CodexProvider] Failed to configure MCP server:", error); + // Continue execution even if MCP config fails + } + } + + // Build combined prompt with conversation history + // Codex CLI doesn't support native conversation history or images, so we extract text + let combinedPrompt = ""; + + if (typeof prompt === "string") { + combinedPrompt = prompt; + } else if (Array.isArray(prompt)) { + // Extract text from content blocks (ignore images - Codex CLI doesn't support vision) + combinedPrompt = prompt + .filter(block => block.type === "text") + .map(block => block.text || "") + .join("\n"); + } + + // Add system prompt first + if (systemPrompt) { + combinedPrompt = `${systemPrompt}\n\n---\n\n${combinedPrompt}`; + } + + // Add conversation history + if (conversationHistory && conversationHistory.length > 0) { + let historyText = "Previous conversation:\n\n"; + for (const msg of conversationHistory) { + const contentText = typeof msg.content === "string" + ? msg.content + : msg.content.map(c => c.text || "").join(""); + historyText += `${msg.role === "user" ? "User" : "Assistant"}: ${contentText}\n\n`; + } + combinedPrompt = `${historyText}---\n\nCurrent request:\n${combinedPrompt}`; + } + + // Build command arguments + const args = this.buildArgs({ prompt: combinedPrompt, model }); + + // Check authentication - either API key or CLI login + const auth = CodexCliDetector.checkAuth(); + const hasApiKey = this.config.apiKey || process.env.OPENAI_API_KEY; + + if (!auth.authenticated && !hasApiKey) { + yield { + type: "error", + error: + "Codex CLI is not authenticated. Please run 'codex login' or set OPENAI_API_KEY environment variable.", + }; + return; + } + + // Prepare environment variables (API key is optional if using CLI auth) + const env = { + ...this.config.env, + ...(hasApiKey && { OPENAI_API_KEY: hasApiKey }), + }; + + // Spawn the Codex process and stream JSONL output + try { + const stream = spawnJSONLProcess({ + command: codexPath, + args, + cwd, + env, + abortController, + timeout: 30000, // 30s timeout for no output + }); + + for await (const event of stream) { + const converted = this.convertToProviderFormat(event as CodexEvent); + if (converted) { + yield converted; + } + } + + // Yield completion event + yield { + type: "result", + subtype: "success", + result: "", + }; + } catch (error) { + console.error("[CodexProvider] Execution error:", error); + yield { + type: "error", + error: (error as Error).message, + }; + } + } + + /** + * Convert Codex JSONL event to Provider message format (Claude SDK compatible) + */ + private convertToProviderFormat(event: CodexEvent): ProviderMessage | null { + const { type, data, item, thread_id } = event; + + switch (type) { + case CODEX_EVENT_TYPES.THREAD_STARTED: + case "thread.started": + // Session initialization - not needed for provider format + return null; + + case CODEX_EVENT_TYPES.ITEM_COMPLETED: + case "item.completed": + return this.convertItemCompleted(item || data); + + case CODEX_EVENT_TYPES.ITEM_STARTED: + case "item.started": + // Item started events can show tool usage + const startedItem = item || data; + if ( + startedItem?.type === "command_execution" && + startedItem?.command + ) { + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: "bash", + input: { command: startedItem.command }, + }, + ], + }, + }; + } + // Handle todo_list started + if (startedItem?.type === "todo_list" && startedItem?.items) { + const todos = startedItem.items || []; + const todoText = todos + .map((t: any, i: number) => `${i + 1}. ${t.text || t}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Todo List:**\n${todoText}`, + }, + ], + }, + }; + } + return null; + + case "item.updated": + // Handle updated items (like todo list updates) + const updatedItem = item || data; + if (updatedItem?.type === "todo_list" && updatedItem?.items) { + const todos = updatedItem.items || []; + const todoText = todos + .map((t: any, i: number) => { + const status = t.status === "completed" ? "āœ“" : " "; + return `${i + 1}. [${status}] ${t.text || t}`; + }) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Updated Todo List:**\n${todoText}`, + }, + ], + }, + }; + } + return null; + + case CODEX_EVENT_TYPES.THREAD_COMPLETED: + case "thread.completed": + return { + type: "result", + subtype: "success", + result: "", + }; + + case CODEX_EVENT_TYPES.ERROR: + case "error": + return { + type: "error", + error: + data?.message || + item?.message || + event.message || + "Unknown error from Codex CLI", + }; + + case "turn.started": + case "turn.completed": + // Turn markers - not needed for provider format + return null; + + default: + return null; + } + } + + /** + * Convert item.completed event to Provider format + */ + private convertItemCompleted(item: any): ProviderMessage | null { + if (!item) { + return null; + } + + const itemType = item.type || item.item_type; + + switch (itemType) { + case "reasoning": + // Thinking/reasoning output + const reasoningText = item.text || item.content || ""; + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: reasoningText, + }, + ], + }, + }; + + case "agent_message": + case "message": + // Assistant text message + const messageText = item.content || item.text || ""; + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: messageText, + }, + ], + }, + }; + + case "command_execution": + // Command execution - show both the command and its output + const command = item.command || ""; + const output = item.aggregated_output || item.output || ""; + + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`, + }, + ], + }, + }; + + case "tool_use": + // Tool use + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: item.tool || item.command || "unknown", + input: item.input || item.args || {}, + }, + ], + }, + }; + + case "tool_result": + // Tool result + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_result", + tool_use_id: item.tool_use_id, + content: item.output || item.result, + }, + ], + }, + }; + + case "todo_list": + // Todo list - convert to text format + const todos = item.items || []; + const todoText = todos + .map((t: any, i: number) => `${i + 1}. ${t.text || t}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Todo List:**\n${todoText}`, + }, + ], + }, + }; + + case "file_change": + // File changes - show what files were modified + const changes = item.changes || []; + const changeText = changes + .map((c: any) => `- Modified: ${c.path}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**File Changes:**\n${changeText}`, + }, + ], + }, + }; + + default: + // Generic text output + const text = item.text || item.content || item.aggregated_output; + if (text) { + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: String(text), + }, + ], + }, + }; + } + return null; + } + } + + /** + * Build command arguments for Codex CLI + */ + private buildArgs(options: { + prompt: string; + model: string; + }): string[] { + const { prompt, model } = options; + + return [ + "exec", + "--model", + model, + "--json", // JSONL output format + "--full-auto", // Non-interactive mode + prompt, // Prompt as the last argument + ]; + } + + /** + * Find Codex CLI executable path + */ + private findCodexPath(): string | null { + // Check config override + if (this.config.cliPath) { + return this.config.cliPath; + } + + // Check environment variable override + if (process.env.CODEX_CLI_PATH) { + return process.env.CODEX_CLI_PATH; + } + + // Auto-detect + const detection = CodexCliDetector.detectCodexInstallation(); + return detection.path || "codex"; + } + + /** + * Get MCP server script path + */ + private async getMcpServerPath(): Promise { + // TODO: Implement MCP server path resolution + // For now, return null - MCP support is optional + return null; + } + + /** + * Detect Codex CLI installation + */ + async detectInstallation(): Promise { + const detection = CodexCliDetector.detectCodexInstallation(); + const auth = CodexCliDetector.checkAuth(); + + return { + installed: detection.installed, + path: detection.path, + version: detection.version, + method: detection.method, + hasApiKey: auth.hasEnvKey || auth.authenticated, + authenticated: auth.authenticated, + }; + } + + /** + * Get available Codex models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: "gpt-5.2", + name: "GPT-5.2 (Codex)", + modelString: "gpt-5.2", + provider: "openai-codex", + description: "Latest Codex model for agentic code generation", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "premium", + default: true, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + modelString: "gpt-5.1-codex-max", + provider: "openai-codex", + description: "Maximum capability Codex model", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "premium", + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + modelString: "gpt-5.1-codex", + provider: "openai-codex", + description: "Standard Codex model", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ["tools", "text", "vision", "mcp", "cli"]; + return supportedFeatures.includes(feature); + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts new file mode 100644 index 00000000..e39eb65f --- /dev/null +++ b/apps/server/src/providers/provider-factory.ts @@ -0,0 +1,126 @@ +/** + * Provider Factory - Routes model IDs to the appropriate provider + * + * This factory implements model-based routing to automatically select + * the correct provider based on the model string. This makes adding + * new providers (Cursor, OpenCode, etc.) trivial - just add one line. + */ + +import { BaseProvider } from "./base-provider.js"; +import { ClaudeProvider } from "./claude-provider.js"; +import { CodexProvider } from "./codex-provider.js"; +import type { InstallationStatus } from "./types.js"; + +export class ProviderFactory { + /** + * Get the appropriate provider for a given model ID + * + * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast") + * @returns Provider instance for the model + */ + static getProviderForModel(modelId: string): BaseProvider { + const lowerModel = modelId.toLowerCase(); + + // OpenAI/Codex models (gpt-*, o1, o3, etc.) + if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) { + return new CodexProvider(); + } + + // Claude models (claude-*, opus, sonnet, haiku) + if ( + lowerModel.startsWith("claude-") || + ["haiku", "sonnet", "opus"].includes(lowerModel) + ) { + return new ClaudeProvider(); + } + + // Future providers: + // if (lowerModel.startsWith("cursor-")) { + // return new CursorProvider(); + // } + // if (lowerModel.startsWith("opencode-")) { + // return new OpenCodeProvider(); + // } + + // Default to Claude for unknown models + console.warn( + `[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude` + ); + return new ClaudeProvider(); + } + + /** + * Get all available providers + */ + static getAllProviders(): BaseProvider[] { + return [ + new ClaudeProvider(), + new CodexProvider(), + // Future providers... + ]; + } + + /** + * Check installation status for all providers + * + * @returns Map of provider name to installation status + */ + static async checkAllProviders(): Promise< + Record + > { + const providers = this.getAllProviders(); + const statuses: Record = {}; + + for (const provider of providers) { + const name = provider.getName(); + const status = await provider.detectInstallation(); + statuses[name] = status; + } + + return statuses; + } + + /** + * Get provider by name (for direct access if needed) + * + * @param name Provider name (e.g., "claude", "codex", "cursor") + * @returns Provider instance or null if not found + */ + static getProviderByName(name: string): BaseProvider | null { + const lowerName = name.toLowerCase(); + + switch (lowerName) { + case "claude": + case "anthropic": + return new ClaudeProvider(); + + case "codex": + case "openai": + return new CodexProvider(); + + // Future providers: + // case "cursor": + // return new CursorProvider(); + // case "opencode": + // return new OpenCodeProvider(); + + default: + return null; + } + } + + /** + * Get all available models from all providers + */ + static getAllAvailableModels() { + const providers = this.getAllProviders(); + const allModels = []; + + for (const provider of providers) { + const models = provider.getAvailableModels(); + allModels.push(...models); + } + + return allModels; + } +} diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts new file mode 100644 index 00000000..24bd41a8 --- /dev/null +++ b/apps/server/src/providers/types.ts @@ -0,0 +1,103 @@ +/** + * Shared types for AI model providers + */ + +/** + * Configuration for a provider instance + */ +export interface ProviderConfig { + apiKey?: string; + cliPath?: string; + env?: Record; +} + +/** + * Message in conversation history + */ +export interface ConversationMessage { + role: "user" | "assistant"; + content: string | Array<{ type: string; text?: string; source?: object }>; +} + +/** + * Options for executing a query via a provider + */ +export interface ExecuteOptions { + prompt: string | Array<{ type: string; text?: string; source?: object }>; + model: string; + cwd: string; + systemPrompt?: string; + maxTurns?: number; + allowedTools?: string[]; + mcpServers?: Record; + abortController?: AbortController; + conversationHistory?: ConversationMessage[]; // Previous messages for context +} + +/** + * Content block in a provider message (matches Claude SDK format) + */ +export interface ContentBlock { + type: "text" | "tool_use" | "thinking" | "tool_result"; + text?: string; + thinking?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; +} + +/** + * Message returned by a provider (matches Claude SDK streaming format) + */ +export interface ProviderMessage { + type: "assistant" | "user" | "error" | "result"; + subtype?: "success" | "error"; + session_id?: string; + message?: { + role: "user" | "assistant"; + content: ContentBlock[]; + }; + result?: string; + error?: string; + parent_tool_use_id?: string | null; +} + +/** + * Installation status for a provider + */ +export interface InstallationStatus { + installed: boolean; + path?: string; + version?: string; + method?: "cli" | "npm" | "brew" | "sdk"; + hasApiKey?: boolean; + authenticated?: boolean; + error?: string; +} + +/** + * Validation result + */ +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings?: string[]; +} + +/** + * Model definition + */ +export interface ModelDefinition { + id: string; + name: string; + modelString: string; + provider: string; + description: string; + contextWindow?: number; + maxOutputTokens?: number; + supportsVision?: boolean; + supportsTools?: boolean; + tier?: "basic" | "standard" | "premium"; + default?: boolean; +} diff --git a/apps/server/src/routes/agent.ts b/apps/server/src/routes/agent.ts index 966b8916..7315125f 100644 --- a/apps/server/src/routes/agent.ts +++ b/apps/server/src/routes/agent.ts @@ -40,11 +40,12 @@ export function createAgentRoutes( // Send a message router.post("/send", async (req: Request, res: Response) => { try { - const { sessionId, message, workingDirectory, imagePaths } = req.body as { + const { sessionId, message, workingDirectory, imagePaths, model } = req.body as { sessionId: string; message: string; workingDirectory?: string; imagePaths?: string[]; + model?: string; }; if (!sessionId || !message) { @@ -61,6 +62,7 @@ export function createAgentRoutes( message, workingDirectory, imagePaths, + model, }) .catch((error) => { console.error("[Agent Route] Error sending message:", error); @@ -128,5 +130,26 @@ export function createAgentRoutes( } }); + // Set session model + router.post("/model", async (req: Request, res: Response) => { + try { + const { sessionId, model } = req.body as { + sessionId: string; + model: string; + }; + + if (!sessionId || !model) { + res.status(400).json({ success: false, error: "sessionId and model are required" }); + return; + } + + const result = await agentService.setSessionModel(sessionId, model); + res.json({ success: result }); + } catch (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/models.ts b/apps/server/src/routes/models.ts index 5856fac5..6738e455 100644 --- a/apps/server/src/routes/models.ts +++ b/apps/server/src/routes/models.ts @@ -3,6 +3,7 @@ */ import { Router, type Request, type Response } from "express"; +import { ProviderFactory } from "../providers/provider-factory.js"; interface ModelDefinition { id: string; @@ -93,7 +94,25 @@ export function createModelsRoutes(): Router { { id: "gpt-5.2", name: "GPT-5.2 (Codex)", - provider: "openai", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + provider: "openai-codex", contextWindow: 256000, maxOutputTokens: 32768, supportsVision: true, @@ -111,15 +130,25 @@ export function createModelsRoutes(): Router { // Check provider status router.get("/providers", async (_req: Request, res: Response) => { try { - const providers: Record = { + // Get installation status from all providers + const statuses = await ProviderFactory.checkAllProviders(); + + const providers: Record = { anthropic: { - available: !!process.env.ANTHROPIC_API_KEY, - hasApiKey: !!process.env.ANTHROPIC_API_KEY, + available: statuses.claude?.installed || false, + hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN, }, openai: { available: !!process.env.OPENAI_API_KEY, hasApiKey: !!process.env.OPENAI_API_KEY, }, + "openai-codex": { + available: statuses.codex?.installed || false, + hasApiKey: !!process.env.OPENAI_API_KEY, + cliInstalled: statuses.codex?.installed, + cliVersion: statuses.codex?.version, + cliPath: statuses.codex?.path, + }, google: { available: !!process.env.GOOGLE_API_KEY, hasApiKey: !!process.env.GOOGLE_API_KEY, diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 62940895..587c88c1 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router { // Create a new session router.post("/", async (req: Request, res: Response) => { try { - const { name, projectPath, workingDirectory } = req.body as { + const { name, projectPath, workingDirectory, model } = req.body as { name: string; projectPath?: string; workingDirectory?: string; + model?: string; }; if (!name) { @@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router { const session = await agentService.createSession( name, projectPath, - workingDirectory + workingDirectory, + model ); res.json({ success: true, session }); } catch (error) { @@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router { router.put("/:sessionId", async (req: Request, res: Response) => { try { const { sessionId } = req.params; - const { name, tags } = req.body as { + const { name, tags, model } = req.body as { name?: string; tags?: string[]; + model?: string; }; - const session = await agentService.updateSession(sessionId, { name, tags }); + const session = await agentService.updateSession(sessionId, { name, tags, model }); if (!session) { res.status(404).json({ success: false, error: "Session not found" }); return; diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 09119be6..b839d452 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -1,12 +1,14 @@ /** - * Agent Service - Runs Claude agents via the Claude Agent SDK + * Agent Service - Runs AI agents via provider architecture * Manages conversation sessions and streams responses via WebSocket */ -import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { AbortError } from "@anthropic-ai/claude-agent-sdk"; import path from "path"; import fs from "fs/promises"; import type { EventEmitter } from "../lib/events.js"; +import { ProviderFactory } from "../providers/provider-factory.js"; +import type { ExecuteOptions } from "../providers/types.js"; interface Message { id: string; @@ -26,6 +28,7 @@ interface Session { isRunning: boolean; abortController: AbortController | null; workingDirectory: string; + model?: string; } interface SessionMetadata { @@ -37,6 +40,7 @@ interface SessionMetadata { updatedAt: string; archived?: boolean; tags?: string[]; + model?: string; } export class AgentService { @@ -91,11 +95,13 @@ export class AgentService { message, workingDirectory, imagePaths, + model, }: { sessionId: string; message: string; workingDirectory?: string; imagePaths?: string[]; + model?: string; }) { const session = this.sessions.get(sessionId); if (!session) { @@ -106,6 +112,12 @@ export class AgentService { throw new Error("Agent is already processing a message"); } + // Update session model if provided + if (model) { + session.model = model; + await this.updateSession(sessionId, { model }); + } + // Read images and convert to base64 const images: Message["images"] = []; if (imagePaths && imagePaths.length > 0) { @@ -143,6 +155,12 @@ export class AgentService { timestamp: new Date().toISOString(), }; + // Build conversation history from existing messages BEFORE adding current message + const conversationHistory = session.messages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); @@ -156,11 +174,23 @@ export class AgentService { await this.saveSession(sessionId, session.messages); try { - const options: Options = { - model: "claude-opus-4-5-20251101", + // Use session model, parameter model, or default + const effectiveModel = model || session.model || "claude-opus-4-5-20251101"; + + // Get provider for this model + const provider = ProviderFactory.getProviderForModel(effectiveModel); + + console.log( + `[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"` + ); + + // Build options for provider + const options: ExecuteOptions = { + prompt: "", // Will be set below based on images + model: effectiveModel, + cwd: workingDirectory || session.workingDirectory, systemPrompt: this.getSystemPrompt(), maxTurns: 20, - cwd: workingDirectory || session.workingDirectory, allowedTools: [ "Read", "Write", @@ -171,23 +201,28 @@ export class AgentService { "WebSearch", "WebFetch", ], - permissionMode: "acceptEdits", - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, abortController: session.abortController!, + conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }; // Build prompt content let promptContent: string | Array<{ type: string; text?: string; source?: object }> = message; + // Append image paths to prompt text (like old implementation) if (imagePaths && imagePaths.length > 0) { + let enhancedMessage = message; + + // Append image file paths to the message text + enhancedMessage += "\n\nAttached images:\n"; + for (const imagePath of imagePaths) { + enhancedMessage += `- ${imagePath}\n`; + } + const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; - if (message && message.trim()) { - contentBlocks.push({ type: "text", text: message }); + if (enhancedMessage && enhancedMessage.trim()) { + contentBlocks.push({ type: "text", text: enhancedMessage }); } for (const imagePath of imagePaths) { @@ -219,25 +254,16 @@ export class AgentService { if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") { promptContent = contentBlocks; + } else { + promptContent = enhancedMessage; } } - // Build payload - const promptPayload = Array.isArray(promptContent) - ? (async function* () { - yield { - type: "user" as const, - session_id: "", - message: { - role: "user" as const, - content: promptContent, - }, - parent_tool_use_id: null, - }; - })() - : promptContent; + // Set the prompt in options + options.prompt = promptContent; - const stream = query({ prompt: promptPayload, options }); + // Execute via provider + const stream = provider.executeQuery(options); let currentAssistantMessage: Message | null = null; let responseText = ""; @@ -245,7 +271,7 @@ export class AgentService { for await (const msg of stream) { if (msg.type === "assistant") { - if (msg.message.content) { + if (msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { responseText += block.text; @@ -270,7 +296,7 @@ export class AgentService { }); } else if (block.type === "tool_use") { const toolUse = { - name: block.name, + name: block.name || "unknown", input: block.input, }; toolUses.push(toolUse); @@ -450,7 +476,8 @@ export class AgentService { async createSession( name: string, projectPath?: string, - workingDirectory?: string + workingDirectory?: string, + model?: string ): Promise { const sessionId = this.generateId(); const metadata = await this.loadMetadata(); @@ -462,6 +489,7 @@ export class AgentService { workingDirectory: workingDirectory || projectPath || process.cwd(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + model, }; metadata[sessionId] = session; @@ -470,6 +498,16 @@ export class AgentService { return session; } + async setSessionModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.model = model; + await this.updateSession(sessionId, { model }); + return true; + } + return false; + } + async updateSession( sessionId: string, updates: Partial diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 4c44cd6e..aadca1b5 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -9,7 +9,9 @@ * - Verification and merge workflows */ -import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { AbortError } from "@anthropic-ai/claude-agent-sdk"; +import { ProviderFactory } from "../providers/provider-factory.js"; +import type { ExecuteOptions } from "../providers/types.js"; import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; @@ -33,6 +35,7 @@ interface Feature { priority?: number; spec?: string; model?: string; // Model to use for this feature + imagePaths?: Array; } /** @@ -237,12 +240,17 @@ export class AutoModeService { // Build the prompt const prompt = this.buildFeaturePrompt(feature); + // Extract image paths from feature + const imagePaths = feature.imagePaths?.map((img) => + typeof img === "string" ? img : img.path + ); + // Get model from feature const model = getModelString(feature); console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`); - // Run the agent with the feature's model - await this.runAgent(workDir, featureId, prompt, abortController, undefined, model); + // Run the agent with the feature's model and images + await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); // Mark as waiting_approval for user review await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); @@ -377,7 +385,126 @@ export class AutoModeService { const model = feature ? getModelString(feature) : MODEL_MAP.opus; console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); - await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); + // Update feature status to in_progress + await this.updateFeatureStatus(projectPath, featureId, "in_progress"); + + // Copy follow-up images to feature folder + const copiedImagePaths: string[] = []; + if (imagePaths && imagePaths.length > 0) { + const featureImagesDir = path.join( + projectPath, + ".automaker", + "features", + featureId, + "images" + ); + + await fs.mkdir(featureImagesDir, { recursive: true }); + + for (const imagePath of imagePaths) { + try { + // Get the filename from the path + const filename = path.basename(imagePath); + const destPath = path.join(featureImagesDir, filename); + + // Copy the image + await fs.copyFile(imagePath, destPath); + + // Store the relative path (like FeatureLoader does) + const relativePath = path.join( + ".automaker", + "features", + featureId, + "images", + filename + ); + copiedImagePaths.push(relativePath); + + } catch (error) { + console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error); + } + } + } + + // Update feature object with new follow-up images BEFORE building prompt + if (copiedImagePaths.length > 0 && feature) { + const currentImagePaths = feature.imagePaths || []; + const newImagePaths = copiedImagePaths.map((p) => ({ + path: p, + filename: path.basename(p), + mimeType: "image/png", // Default, could be improved + })); + + feature.imagePaths = [...currentImagePaths, ...newImagePaths]; + } + + // Load previous agent output for context + const outputPath = path.join( + workDir, + ".automaker", + "features", + featureId, + "agent-output.md" + ); + let previousContext = ""; + try { + previousContext = await fs.readFile(outputPath, "utf-8"); + } catch { + // No previous context + } + + // Build follow-up prompt with context (feature now includes new images) + let followUpPrompt = prompt; + if (previousContext) { + followUpPrompt = `## Follow-up Request + +${this.buildFeaturePrompt(feature!)} + +## Previous Work +The following is the output from the previous implementation: + +${previousContext} + +--- + +## New Instructions +${prompt} + +Please continue from where you left off and address the new instructions above.`; + } + + // Combine original feature images with new follow-up images + const allImagePaths: string[] = []; + + // Add all images from feature (now includes both original and new) + if (feature?.imagePaths) { + const allPaths = feature.imagePaths.map((img) => + typeof img === "string" ? img : img.path + ); + allImagePaths.push(...allPaths); + } + + // Save updated feature.json with new images + if (copiedImagePaths.length > 0 && feature) { + const featurePath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "feature.json" + ); + + try { + await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + } catch (error) { + console.error(`[AutoMode] Failed to save feature.json:`, error); + } + } + + await this.runAgent(workDir, featureId, followUpPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : undefined, model); + + // Mark as waiting_approval for user review + await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -544,23 +671,25 @@ export class AutoModeService { Format your response as a structured markdown document.`; try { - const options: Options = { + const provider = ProviderFactory.getProviderForModel("claude-sonnet-4-20250514"); + + const options: ExecuteOptions = { + prompt, model: "claude-sonnet-4-20250514", maxTurns: 5, cwd: projectPath, allowedTools: ["Read", "Glob", "Grep"], - permissionMode: "acceptEdits", abortController, }; - const stream = query({ prompt, options }); + const stream = provider.executeQuery(options); let analysisResult = ""; for await (const msg of stream) { - if (msg.type === "assistant" && msg.message.content) { + if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { - analysisResult = block.text; + analysisResult = block.text || ""; this.emitAutoModeEvent("auto_mode_progress", { featureId: analysisFeatureId, content: block.text, @@ -736,6 +865,27 @@ ${feature.spec} `; } + // Add images note (like old implementation) + if (feature.imagePaths && feature.imagePaths.length > 0) { + const imagesList = feature.imagePaths + .map((img, idx) => { + const path = typeof img === "string" ? img : img.path; + const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop(); + const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*"; + return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; + }) + .join("\n"); + + prompt += ` +**šŸ“Ž Context Images Attached:** +The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read: + +${imagesList} + +You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing. +`; + } + prompt += ` ## Instructions @@ -761,17 +911,62 @@ When done, summarize what you implemented and any notes for the developer.`; ): Promise { const finalModel = model || MODEL_MAP.opus; console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`); - - // Check if this is an OpenAI/Codex model - Claude Agent SDK doesn't support these - if (finalModel.startsWith("gpt-") || finalModel.startsWith("o")) { - const errorMessage = `OpenAI/Codex models (like "${finalModel}") are not yet supported in server mode. ` + - `Please use a Claude model (opus, sonnet, or haiku) instead. ` + - `OpenAI/Codex models are only supported in the Electron app.`; - console.error(`[AutoMode] ${errorMessage}`); - throw new Error(errorMessage); + + // Get provider for this model + const provider = ProviderFactory.getProviderForModel(finalModel); + + console.log( + `[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"` + ); + + // Build prompt content with images (like AgentService) + let promptContent: string | Array<{ type: string; text?: string; source?: object }> = prompt; + + if (imagePaths && imagePaths.length > 0) { + const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; + + // Add text block first + contentBlocks.push({ type: "text", text: prompt }); + + // Add image blocks (for vision models) + for (const imagePath of imagePaths) { + try { + // Make path absolute by prepending workDir if it's relative + const absolutePath = path.isAbsolute(imagePath) + ? imagePath + : path.join(workDir, imagePath); + + const imageBuffer = await fs.readFile(absolutePath); + const base64Data = imageBuffer.toString("base64"); + const ext = path.extname(imagePath).toLowerCase(); + const mimeTypeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }; + const mediaType = mimeTypeMap[ext] || "image/png"; + + contentBlocks.push({ + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64Data, + }, + }); + + } catch (error) { + console.error(`[AutoMode] Failed to load image ${imagePath}:`, error); + } + } + + promptContent = contentBlocks; } - - const options: Options = { + + const options: ExecuteOptions = { + prompt: promptContent, model: finalModel, maxTurns: 50, cwd: workDir, @@ -783,35 +978,24 @@ When done, summarize what you implemented and any notes for the developer.`; "Grep", "Bash", ], - permissionMode: "acceptEdits", - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, abortController, }; - // Build prompt - include image paths for the agent to read - let finalPrompt = prompt; - - if (imagePaths && imagePaths.length > 0) { - finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`; - } - - const stream = query({ prompt: finalPrompt, options }); + // Execute via provider + const stream = provider.executeQuery(options); let responseText = ""; const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md"); for await (const msg of stream) { - if (msg.type === "assistant" && msg.message.content) { + if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { - responseText = block.text; + responseText = block.text || ""; // Check for authentication errors in the response - if (block.text.includes("Invalid API key") || + if (block.text && (block.text.includes("Invalid API key") || block.text.includes("authentication_failed") || - block.text.includes("Fix external API key")) { + block.text.includes("Fix external API key"))) { throw new Error( "Authentication failed: Invalid or expired API key. " + "Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate." @@ -830,20 +1014,10 @@ When done, summarize what you implemented and any notes for the developer.`; }); } } - } else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") { - // Handle authentication error from the SDK - throw new Error( - "Authentication failed: Invalid or expired API key. " + - "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." - ); + } else if (msg.type === "error") { + // Handle error messages + throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { - // Check if result indicates an error - if (msg.is_error && msg.result?.includes("Invalid API key")) { - throw new Error( - "Authentication failed: Invalid or expired API key. " + - "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." - ); - } responseText = msg.result || responseText; } } From 0519aba8203994907184ee3a3d117133ceafef79 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 03:50:31 +0100 Subject: [PATCH 03/47] feat: add missing Codex models and restore subprocess logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added gpt-5.1-codex-mini model (lightweight, faster) - Added gpt-5.1 model (general-purpose) - Restored subprocess spawn/exit logs for debugging - Now all 5 Codex models are available: * GPT-5.2 * GPT-5.1 Codex Max * GPT-5.1 Codex * GPT-5.1 Codex Mini * GPT-5.1 šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/lib/subprocess-manager.ts | 6 +++++- apps/server/src/providers/codex-provider.ts | 24 +++++++++++++++++++++ apps/server/src/routes/models.ts | 18 ++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/server/src/lib/subprocess-manager.ts b/apps/server/src/lib/subprocess-manager.ts index ddfc7485..bb03d288 100644 --- a/apps/server/src/lib/subprocess-manager.ts +++ b/apps/server/src/lib/subprocess-manager.ts @@ -33,6 +33,9 @@ export async function* spawnJSONLProcess( ...env, }; + console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`); + console.log(`[SubprocessManager] Working directory: ${cwd}`); + const childProcess: ChildProcess = spawn(command, args, { cwd, env: processEnv, @@ -123,6 +126,7 @@ export async function* spawnJSONLProcess( // Wait for process to exit const exitCode = await new Promise((resolve) => { childProcess.on("exit", (code) => { + console.log(`[SubprocessManager] Process exited with code: ${code}`); resolve(code); }); @@ -144,7 +148,7 @@ export async function* spawnJSONLProcess( // Process completed successfully if (exitCode === 0 && !stderrOutput) { - // Success - no logging needed + console.log("[SubprocessManager] Process completed successfully"); } } diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 0c608def..4739b7c8 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -537,6 +537,30 @@ export class CodexProvider extends BaseProvider { supportsTools: true, tier: "standard", }, + { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex Mini", + modelString: "gpt-5.1-codex-mini", + provider: "openai-codex", + description: "Faster, lightweight Codex model", + contextWindow: 256000, + maxOutputTokens: 16384, + supportsVision: false, + supportsTools: true, + tier: "basic", + }, + { + id: "gpt-5.1", + name: "GPT-5.1", + modelString: "gpt-5.1", + provider: "openai-codex", + description: "General-purpose GPT-5.1 model", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, ]; } diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts index 6738e455..c45bae19 100644 --- a/apps/server/src/routes/models.ts +++ b/apps/server/src/routes/models.ts @@ -118,6 +118,24 @@ export function createModelsRoutes(): Router { supportsVision: true, supportsTools: true, }, + { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex Mini", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 16384, + supportsVision: false, + supportsTools: true, + }, + { + id: "gpt-5.1", + name: "GPT-5.1", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, ]; res.json({ success: true, models }); From 7cbdb3db7344dadc049e260915169737ba6c7fce Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 04:26:58 +0100 Subject: [PATCH 04/47] refactor: eliminate code duplication with shared utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created 5 new utility modules in apps/server/src/lib/ to eliminate ~320 lines of duplicated code: - image-handler.ts: Centralized image processing (MIME types, base64, content blocks) - prompt-builder.ts: Standardized prompt building with image attachments - model-resolver.ts: Model alias resolution and provider routing - conversation-utils.ts: Conversation history processing for providers - error-handler.ts: Error classification and user-friendly messages Updated services and providers to use shared utilities: - agent-service.ts: -51 lines (removed duplicate image handling, model logic) - auto-mode-service.ts: -75 lines (removed MODEL_MAP, duplicate utilities) - claude-provider.ts: -10 lines (uses conversation-utils) - codex-provider.ts: -5 lines (uses conversation-utils) Added comprehensive documentation: - docs/server/utilities.md: Complete reference for all 9 lib utilities - docs/server/providers.md: Provider architecture guide with examples Benefits: - Single source of truth for critical business logic - Improved maintainability and testability - Consistent behavior across services and providers - Better documentation for future development šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/lib/conversation-utils.ts | 97 +++ apps/server/src/lib/error-handler.ts | 104 +++ apps/server/src/lib/image-handler.ts | 135 +++ apps/server/src/lib/model-resolver.ts | 88 ++ apps/server/src/lib/prompt-builder.ts | 79 ++ apps/server/src/providers/claude-provider.ts | 22 +- apps/server/src/providers/codex-provider.ts | 11 +- apps/server/src/services/agent-service.ts | 89 +- apps/server/src/services/auto-mode-service.ts | 113 +-- docs/server/providers.md | 786 ++++++++++++++++++ docs/server/utilities.md | 672 +++++++++++++++ 11 files changed, 2008 insertions(+), 188 deletions(-) create mode 100644 apps/server/src/lib/conversation-utils.ts create mode 100644 apps/server/src/lib/error-handler.ts create mode 100644 apps/server/src/lib/image-handler.ts create mode 100644 apps/server/src/lib/model-resolver.ts create mode 100644 apps/server/src/lib/prompt-builder.ts create mode 100644 docs/server/providers.md create mode 100644 docs/server/utilities.md diff --git a/apps/server/src/lib/conversation-utils.ts b/apps/server/src/lib/conversation-utils.ts new file mode 100644 index 00000000..3fe95a60 --- /dev/null +++ b/apps/server/src/lib/conversation-utils.ts @@ -0,0 +1,97 @@ +/** + * Conversation history utilities for processing message history + * + * Provides standardized conversation history handling: + * - Extract text from content (string or array format) + * - Normalize content blocks to array format + * - Format history as plain text for CLI-based providers + * - Convert history to Claude SDK message format + */ + +import type { ConversationMessage } from "../providers/types.js"; + +/** + * Extract plain text from message content (handles both string and array formats) + * + * @param content - Message content (string or array of content blocks) + * @returns Extracted text content + */ +export function extractTextFromContent( + content: string | Array<{ type: string; text?: string; source?: object }> +): string { + if (typeof content === "string") { + return content; + } + + // Extract text blocks only + return content + .filter((block) => block.type === "text") + .map((block) => block.text || "") + .join("\n"); +} + +/** + * Normalize message content to array format + * + * @param content - Message content (string or array) + * @returns Content as array of blocks + */ +export function normalizeContentBlocks( + content: string | Array<{ type: string; text?: string; source?: object }> +): Array<{ type: string; text?: string; source?: object }> { + if (Array.isArray(content)) { + return content; + } + return [{ type: "text", text: content }]; +} + +/** + * Format conversation history as plain text for CLI-based providers + * + * @param history - Array of conversation messages + * @returns Formatted text with role labels + */ +export function formatHistoryAsText(history: ConversationMessage[]): string { + if (history.length === 0) { + return ""; + } + + let historyText = "Previous conversation:\n\n"; + + for (const msg of history) { + const contentText = extractTextFromContent(msg.content); + const role = msg.role === "user" ? "User" : "Assistant"; + historyText += `${role}: ${contentText}\n\n`; + } + + historyText += "---\n\n"; + return historyText; +} + +/** + * Convert conversation history to Claude SDK message format + * + * @param history - Array of conversation messages + * @returns Array of Claude SDK formatted messages + */ +export function convertHistoryToMessages( + history: ConversationMessage[] +): Array<{ + type: "user" | "assistant"; + session_id: string; + message: { + role: "user" | "assistant"; + content: Array<{ type: string; text?: string; source?: object }>; + }; + parent_tool_use_id: null; +}> { + return history.map((historyMsg) => ({ + type: historyMsg.role, + session_id: "", + message: { + role: historyMsg.role, + content: normalizeContentBlocks(historyMsg.content), + }, + parent_tool_use_id: null, + })); +} diff --git a/apps/server/src/lib/error-handler.ts b/apps/server/src/lib/error-handler.ts new file mode 100644 index 00000000..b1e0c3ac --- /dev/null +++ b/apps/server/src/lib/error-handler.ts @@ -0,0 +1,104 @@ +/** + * Error handling utilities for standardized error classification + * + * Provides utilities for: + * - Detecting abort/cancellation errors + * - Detecting authentication errors + * - Classifying errors by type + * - Generating user-friendly error messages + */ + +/** + * Check if an error is an abort/cancellation error + * + * @param error - The error to check + * @returns True if the error is an abort error + */ +export function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || error.message.includes("abort")) + ); +} + +/** + * Check if an error is an authentication/API key error + * + * @param errorMessage - The error message to check + * @returns True if the error is authentication-related + */ +export function isAuthenticationError(errorMessage: string): boolean { + return ( + errorMessage.includes("Authentication failed") || + errorMessage.includes("Invalid API key") || + errorMessage.includes("authentication_failed") || + errorMessage.includes("Fix external API key") + ); +} + +/** + * Error type classification + */ +export type ErrorType = "authentication" | "abort" | "execution" | "unknown"; + +/** + * Classified error information + */ +export interface ErrorInfo { + type: ErrorType; + message: string; + isAbort: boolean; + isAuth: boolean; + originalError: unknown; +} + +/** + * Classify an error into a specific type + * + * @param error - The error to classify + * @returns Classified error information + */ +export function classifyError(error: unknown): ErrorInfo { + const message = error instanceof Error ? error.message : String(error || "Unknown error"); + const isAbort = isAbortError(error); + const isAuth = isAuthenticationError(message); + + let type: ErrorType; + if (isAuth) { + type = "authentication"; + } else if (isAbort) { + type = "abort"; + } else if (error instanceof Error) { + type = "execution"; + } else { + type = "unknown"; + } + + return { + type, + message, + isAbort, + isAuth, + originalError: error, + }; +} + +/** + * Get a user-friendly error message + * + * @param error - The error to convert + * @returns User-friendly error message + */ +export function getUserFriendlyErrorMessage(error: unknown): string { + const info = classifyError(error); + + if (info.isAbort) { + return "Operation was cancelled"; + } + + if (info.isAuth) { + return "Authentication failed. Please check your API key."; + } + + return info.message; +} diff --git a/apps/server/src/lib/image-handler.ts b/apps/server/src/lib/image-handler.ts new file mode 100644 index 00000000..167f948f --- /dev/null +++ b/apps/server/src/lib/image-handler.ts @@ -0,0 +1,135 @@ +/** + * Image handling utilities for processing image files + * + * Provides utilities for: + * - MIME type detection based on file extensions + * - Base64 encoding of image files + * - Content block generation for Claude SDK format + * - Path resolution (relative/absolute) + */ + +import fs from "fs/promises"; +import path from "path"; + +/** + * MIME type mapping for image file extensions + */ +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +} as const; + +/** + * Image data with base64 encoding and metadata + */ +export interface ImageData { + base64: string; + mimeType: string; + filename: string; + originalPath: string; +} + +/** + * Content block for image (Claude SDK format) + */ +export interface ImageContentBlock { + type: "image"; + source: { + type: "base64"; + media_type: string; + data: string; + }; +} + +/** + * Get MIME type for an image file based on extension + * + * @param imagePath - Path to the image file + * @returns MIME type string (defaults to "image/png" for unknown extensions) + */ +export function getMimeTypeForImage(imagePath: string): string { + const ext = path.extname(imagePath).toLowerCase(); + return IMAGE_MIME_TYPES[ext] || "image/png"; +} + +/** + * Read an image file and convert to base64 with metadata + * + * @param imagePath - Path to the image file + * @returns Promise resolving to image data with base64 encoding + * @throws Error if file cannot be read + */ +export async function readImageAsBase64(imagePath: string): Promise { + const imageBuffer = await fs.readFile(imagePath); + const base64Data = imageBuffer.toString("base64"); + const mimeType = getMimeTypeForImage(imagePath); + + return { + base64: base64Data, + mimeType, + filename: path.basename(imagePath), + originalPath: imagePath, + }; +} + +/** + * Convert image paths to content blocks (Claude SDK format) + * Handles both relative and absolute paths + * + * @param imagePaths - Array of image file paths + * @param workDir - Optional working directory for resolving relative paths + * @returns Promise resolving to array of image content blocks + */ +export async function convertImagesToContentBlocks( + imagePaths: string[], + workDir?: string +): Promise { + const blocks: ImageContentBlock[] = []; + + for (const imagePath of imagePaths) { + try { + // Resolve to absolute path if needed + const absolutePath = workDir && !path.isAbsolute(imagePath) + ? path.join(workDir, imagePath) + : imagePath; + + const imageData = await readImageAsBase64(absolutePath); + + blocks.push({ + type: "image", + source: { + type: "base64", + media_type: imageData.mimeType, + data: imageData.base64, + }, + }); + } catch (error) { + console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error); + // Continue processing other images + } + } + + return blocks; +} + +/** + * Build a list of image paths for text prompts + * Formats image paths as a bulleted list for inclusion in text prompts + * + * @param imagePaths - Array of image file paths + * @returns Formatted string with image paths, or empty string if no images + */ +export function formatImagePathsForPrompt(imagePaths: string[]): string { + if (imagePaths.length === 0) { + return ""; + } + + let text = "\n\nAttached images:\n"; + for (const imagePath of imagePaths) { + text += `- ${imagePath}\n`; + } + return text; +} diff --git a/apps/server/src/lib/model-resolver.ts b/apps/server/src/lib/model-resolver.ts new file mode 100644 index 00000000..f524dbdf --- /dev/null +++ b/apps/server/src/lib/model-resolver.ts @@ -0,0 +1,88 @@ +/** + * Model resolution utilities for handling model string mapping + * + * Provides centralized model resolution logic: + * - Maps Claude model aliases to full model strings + * - Detects and passes through OpenAI/Codex models + * - Provides default models per provider + * - Handles multiple model sources with priority + */ + +/** + * Model alias mapping for Claude models + */ +export const CLAUDE_MODEL_MAP: Record = { + haiku: "claude-haiku-4-5", + sonnet: "claude-sonnet-4-20250514", + opus: "claude-opus-4-5-20251101", +} as const; + +/** + * Default models per provider + */ +export const DEFAULT_MODELS = { + claude: "claude-opus-4-5-20251101", + openai: "gpt-5.2", +} as const; + +/** + * Resolve a model key/alias to a full model string + * + * @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514") + * @param defaultModel - Fallback model if modelKey is undefined + * @returns Full model string + */ +export function resolveModelString( + modelKey?: string, + defaultModel: string = DEFAULT_MODELS.claude +): string { + // No model specified - use default + if (!modelKey) { + return defaultModel; + } + + // OpenAI/Codex models - pass through unchanged + if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) { + console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`); + return modelKey; + } + + // Full Claude model string - pass through unchanged + if (modelKey.includes("claude-")) { + console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`); + return modelKey; + } + + // Look up Claude model alias + const resolved = CLAUDE_MODEL_MAP[modelKey]; + if (resolved) { + console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`); + return resolved; + } + + // Unknown model key - use default + console.warn( + `[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"` + ); + return defaultModel; +} + +/** + * Get the effective model from multiple sources + * Priority: explicit model > session model > default + * + * @param explicitModel - Explicitly provided model (highest priority) + * @param sessionModel - Model from session (medium priority) + * @param defaultModel - Fallback default model (lowest priority) + * @returns Resolved model string + */ +export function getEffectiveModel( + explicitModel?: string, + sessionModel?: string, + defaultModel?: string +): string { + return resolveModelString( + explicitModel || sessionModel, + defaultModel + ); +} diff --git a/apps/server/src/lib/prompt-builder.ts b/apps/server/src/lib/prompt-builder.ts new file mode 100644 index 00000000..c6ce2e7d --- /dev/null +++ b/apps/server/src/lib/prompt-builder.ts @@ -0,0 +1,79 @@ +/** + * Prompt building utilities for constructing prompts with images + * + * Provides standardized prompt building that: + * - Combines text prompts with image attachments + * - Handles content block array generation + * - Optionally includes image paths in text + * - Supports both vision and non-vision models + */ + +import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js"; + +/** + * Content that can be either simple text or structured blocks + */ +export type PromptContent = string | Array<{ + type: string; + text?: string; + source?: object; +}>; + +/** + * Result of building a prompt with optional images + */ +export interface PromptWithImages { + content: PromptContent; + hasImages: boolean; +} + +/** + * Build a prompt with optional image attachments + * + * @param basePrompt - The text prompt + * @param imagePaths - Optional array of image file paths + * @param workDir - Optional working directory for resolving relative paths + * @param includeImagePaths - Whether to append image paths to the text (default: false) + * @returns Promise resolving to prompt content and metadata + */ +export async function buildPromptWithImages( + basePrompt: string, + imagePaths?: string[], + workDir?: string, + includeImagePaths: boolean = false +): Promise { + // No images - return plain text + if (!imagePaths || imagePaths.length === 0) { + return { content: basePrompt, hasImages: false }; + } + + // Build text content with optional image path listing + let textContent = basePrompt; + if (includeImagePaths) { + textContent += formatImagePathsForPrompt(imagePaths); + } + + // Build content blocks array + const contentBlocks: Array<{ + type: string; + text?: string; + source?: object; + }> = []; + + // Add text block if we have text + if (textContent.trim()) { + contentBlocks.push({ type: "text", text: textContent }); + } + + // Add image blocks + const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir); + contentBlocks.push(...imageBlocks); + + // Return appropriate format + const content: PromptContent = + contentBlocks.length > 1 || contentBlocks[0]?.type === "image" + ? contentBlocks + : textContent; + + return { content, hasImages: true }; +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 9384ad34..c1456c63 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -7,6 +7,7 @@ import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { BaseProvider } from "./base-provider.js"; +import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js"; import type { ExecuteOptions, ProviderMessage, @@ -64,19 +65,10 @@ export class ClaudeProvider extends BaseProvider { if (conversationHistory && conversationHistory.length > 0) { // Multi-turn conversation with history promptPayload = (async function* () { - // Yield all previous messages first - for (const historyMsg of conversationHistory) { - yield { - type: historyMsg.role, - session_id: "", - message: { - role: historyMsg.role, - content: Array.isArray(historyMsg.content) - ? historyMsg.content - : [{ type: "text", text: historyMsg.content }], - }, - parent_tool_use_id: null, - }; + // Yield history messages using utility + const historyMessages = convertHistoryToMessages(conversationHistory); + for (const msg of historyMessages) { + yield msg; } // Yield current prompt @@ -85,9 +77,7 @@ export class ClaudeProvider extends BaseProvider { session_id: "", message: { role: "user" as const, - content: Array.isArray(prompt) - ? prompt - : [{ type: "text", text: prompt }], + content: normalizeContentBlocks(prompt), }, parent_tool_use_id: null, }; diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 4739b7c8..531dd268 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -9,6 +9,7 @@ import { BaseProvider } from "./base-provider.js"; import { CodexCliDetector } from "./codex-cli-detector.js"; import { codexConfigManager } from "./codex-config-manager.js"; import { spawnJSONLProcess } from "../lib/subprocess-manager.js"; +import { formatHistoryAsText } from "../lib/conversation-utils.js"; import type { ExecuteOptions, ProviderMessage, @@ -99,14 +100,8 @@ export class CodexProvider extends BaseProvider { // Add conversation history if (conversationHistory && conversationHistory.length > 0) { - let historyText = "Previous conversation:\n\n"; - for (const msg of conversationHistory) { - const contentText = typeof msg.content === "string" - ? msg.content - : msg.content.map(c => c.text || "").join(""); - historyText += `${msg.role === "user" ? "User" : "Assistant"}: ${contentText}\n\n`; - } - combinedPrompt = `${historyText}---\n\nCurrent request:\n${combinedPrompt}`; + const historyText = formatHistoryAsText(conversationHistory); + combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`; } // Build command arguments diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index b839d452..b1c8cd1b 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -9,6 +9,12 @@ import fs from "fs/promises"; import type { EventEmitter } from "../lib/events.js"; import { ProviderFactory } from "../providers/provider-factory.js"; import type { ExecuteOptions } from "../providers/types.js"; +import { + readImageAsBase64, +} from "../lib/image-handler.js"; +import { buildPromptWithImages } from "../lib/prompt-builder.js"; +import { getEffectiveModel } from "../lib/model-resolver.js"; +import { isAbortError } from "../lib/error-handler.js"; interface Message { id: string; @@ -123,22 +129,11 @@ export class AgentService { if (imagePaths && imagePaths.length > 0) { for (const imagePath of imagePaths) { try { - const imageBuffer = await fs.readFile(imagePath); - const base64Data = imageBuffer.toString("base64"); - const ext = path.extname(imagePath).toLowerCase(); - const mimeTypeMap: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - }; - const mediaType = mimeTypeMap[ext] || "image/png"; - + const imageData = await readImageAsBase64(imagePath); images.push({ - data: base64Data, - mimeType: mediaType, - filename: path.basename(imagePath), + data: imageData.base64, + mimeType: imageData.mimeType, + filename: imageData.filename, }); } catch (error) { console.error(`[AgentService] Failed to load image ${imagePath}:`, error); @@ -175,7 +170,7 @@ export class AgentService { try { // Use session model, parameter model, or default - const effectiveModel = model || session.model || "claude-opus-4-5-20251101"; + const effectiveModel = getEffectiveModel(model, session.model); // Get provider for this model const provider = ProviderFactory.getProviderForModel(effectiveModel); @@ -205,59 +200,13 @@ export class AgentService { conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }; - // Build prompt content - let promptContent: string | Array<{ type: string; text?: string; source?: object }> = - message; - - // Append image paths to prompt text (like old implementation) - if (imagePaths && imagePaths.length > 0) { - let enhancedMessage = message; - - // Append image file paths to the message text - enhancedMessage += "\n\nAttached images:\n"; - for (const imagePath of imagePaths) { - enhancedMessage += `- ${imagePath}\n`; - } - - const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; - - if (enhancedMessage && enhancedMessage.trim()) { - contentBlocks.push({ type: "text", text: enhancedMessage }); - } - - for (const imagePath of imagePaths) { - try { - const imageBuffer = await fs.readFile(imagePath); - const base64Data = imageBuffer.toString("base64"); - const ext = path.extname(imagePath).toLowerCase(); - const mimeTypeMap: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - }; - const mediaType = mimeTypeMap[ext] || "image/png"; - - contentBlocks.push({ - type: "image", - source: { - type: "base64", - media_type: mediaType, - data: base64Data, - }, - }); - } catch (error) { - console.error(`[AgentService] Failed to load image ${imagePath}:`, error); - } - } - - if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") { - promptContent = contentBlocks; - } else { - promptContent = enhancedMessage; - } - } + // Build prompt content with images + const { content: promptContent } = await buildPromptWithImages( + message, + imagePaths, + undefined, // no workDir for agent service + true // include image paths in text + ); // Set the prompt in options options.prompt = promptContent; @@ -335,7 +284,7 @@ export class AgentService { message: currentAssistantMessage, }; } catch (error) { - if (error instanceof AbortError || (error as Error)?.name === "AbortError") { + if (isAbortError(error)) { session.isRunning = false; session.abortController = null; return { success: false, aborted: true }; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index aadca1b5..e38351e9 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -17,16 +17,12 @@ import { promisify } from "util"; import path from "path"; import fs from "fs/promises"; import type { EventEmitter, EventType } from "../lib/events.js"; +import { buildPromptWithImages } from "../lib/prompt-builder.js"; +import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; +import { isAbortError, classifyError } from "../lib/error-handler.js"; const execAsync = promisify(exec); -// Model name mappings for Claude (matching electron version) -const MODEL_MAP: Record = { - haiku: "claude-haiku-4-5", - sonnet: "claude-sonnet-4-20250514", - opus: "claude-opus-4-5-20251101", -}; - interface Feature { id: string; title: string; @@ -38,36 +34,6 @@ interface Feature { imagePaths?: Array; } -/** - * Get model string from feature's model property - * Supports model keys like "opus", "sonnet", "haiku" or full model strings - * Also supports OpenAI/Codex models like "gpt-5.2", "gpt-5.1-codex", etc. - */ -function getModelString(feature: Feature): string { - const modelKey = feature.model || "opus"; // Default to opus - - // Check if it's an OpenAI/Codex model (starts with "gpt-" or "o" for O-series) - if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) { - console.log(`[AutoMode] Using OpenAI/Codex model from feature ${feature.id}: ${modelKey} (passing through)`); - return modelKey; - } - - // If it's already a full Claude model string (contains "claude-"), use it directly - if (modelKey.includes("claude-")) { - console.log(`[AutoMode] Using Claude model from feature ${feature.id}: ${modelKey} (full model string)`); - return modelKey; - } - - // Otherwise, look it up in the Claude model map - const modelString = MODEL_MAP[modelKey] || MODEL_MAP.opus; - if (modelString !== MODEL_MAP.opus || modelKey === "opus") { - console.log(`[AutoMode] Resolved Claude model for feature ${feature.id}: "${modelKey}" -> "${modelString}"`); - } else { - console.warn(`[AutoMode] Unknown model key "${modelKey}" for feature ${feature.id}, defaulting to "${modelString}"`); - } - return modelString; -} - interface RunningFeature { featureId: string; projectPath: string; @@ -246,7 +212,7 @@ export class AutoModeService { ); // Get model from feature - const model = getModelString(feature); + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`); // Run the agent with the feature's model and images @@ -262,7 +228,9 @@ export class AutoModeService { projectPath, }); } catch (error) { - if (error instanceof AbortError || (error as Error)?.name === "AbortError") { + const errorInfo = classifyError(error); + + if (errorInfo.isAbort) { this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, passes: false, @@ -270,17 +238,12 @@ export class AutoModeService { projectPath, }); } else { - const errorMessage = (error as Error).message || "Unknown error"; - const isAuthError = errorMessage.includes("Authentication failed") || - errorMessage.includes("Invalid API key") || - errorMessage.includes("authentication_failed"); - console.error(`[AutoMode] Feature ${featureId} failed:`, error); await this.updateFeatureStatus(projectPath, featureId, "backlog"); this.emitAutoModeEvent("auto_mode_error", { featureId, - error: errorMessage, - errorType: isAuthError ? "authentication" : "execution", + error: errorInfo.message, + errorType: errorInfo.isAuth ? "authentication" : "execution", projectPath, }); } @@ -382,7 +345,7 @@ export class AutoModeService { try { // Load feature to get its model const feature = await this.loadFeature(projectPath, featureId); - const model = feature ? getModelString(feature) : MODEL_MAP.opus; + const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); // Update feature status to in_progress @@ -513,7 +476,7 @@ Please continue from where you left off and address the new instructions above.` projectPath, }); } catch (error) { - if (!(error instanceof AbortError)) { + if (!isAbortError(error)) { this.emitAutoModeEvent("auto_mode_error", { featureId, error: (error as Error).message, @@ -909,7 +872,7 @@ When done, summarize what you implemented and any notes for the developer.`; imagePaths?: string[], model?: string ): Promise { - const finalModel = model || MODEL_MAP.opus; + const finalModel = resolveModelString(model, DEFAULT_MODELS.claude); console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`); // Get provider for this model @@ -919,51 +882,13 @@ When done, summarize what you implemented and any notes for the developer.`; `[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"` ); - // Build prompt content with images (like AgentService) - let promptContent: string | Array<{ type: string; text?: string; source?: object }> = prompt; - - if (imagePaths && imagePaths.length > 0) { - const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; - - // Add text block first - contentBlocks.push({ type: "text", text: prompt }); - - // Add image blocks (for vision models) - for (const imagePath of imagePaths) { - try { - // Make path absolute by prepending workDir if it's relative - const absolutePath = path.isAbsolute(imagePath) - ? imagePath - : path.join(workDir, imagePath); - - const imageBuffer = await fs.readFile(absolutePath); - const base64Data = imageBuffer.toString("base64"); - const ext = path.extname(imagePath).toLowerCase(); - const mimeTypeMap: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - }; - const mediaType = mimeTypeMap[ext] || "image/png"; - - contentBlocks.push({ - type: "image", - source: { - type: "base64", - media_type: mediaType, - data: base64Data, - }, - }); - - } catch (error) { - console.error(`[AutoMode] Failed to load image ${imagePath}:`, error); - } - } - - promptContent = contentBlocks; - } + // Build prompt content with images using utility + const { content: promptContent } = await buildPromptWithImages( + prompt, + imagePaths, + workDir, + false // don't duplicate paths in text + ); const options: ExecuteOptions = { prompt: promptContent, diff --git a/docs/server/providers.md b/docs/server/providers.md new file mode 100644 index 00000000..ed591bda --- /dev/null +++ b/docs/server/providers.md @@ -0,0 +1,786 @@ +# Provider Architecture Reference + +This document describes the modular provider architecture in `apps/server/src/providers/` that enables support for multiple AI model providers (Claude SDK, OpenAI Codex CLI, and future providers like Cursor, OpenCode, etc.). + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Provider Interface](#provider-interface) +3. [Available Providers](#available-providers) +4. [Provider Factory](#provider-factory) +5. [Adding New Providers](#adding-new-providers) +6. [Provider Types](#provider-types) +7. [Best Practices](#best-practices) + +--- + +## Architecture Overview + +The provider architecture separates AI model execution logic from business logic, enabling clean abstraction and easy extensibility. + +### Architecture Diagram + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ AgentService / AutoModeService │ +│ (No provider logic) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ ProviderFactory │ Model-based routing + │ (Routes by model) │ "gpt-*" → Codex + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ "claude-*" → Claude + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ │ +ā”Œā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Claude │ │ Codex │ +│ Provider │ │ Provider │ +│ (Agent SDK)│ │ (CLI Spawn) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Key Benefits + +- āœ… **Adding new providers**: Only 1 new file + 1 line in factory +- āœ… **Services remain clean**: No provider-specific logic +- āœ… **All providers implement same interface**: Consistent behavior +- āœ… **Model prefix determines provider**: Automatic routing +- āœ… **Easy to test**: Each provider can be tested independently + +--- + +## Provider Interface + +**Location**: `apps/server/src/providers/base-provider.ts` + +All providers must extend `BaseProvider` and implement the required methods. + +### BaseProvider Abstract Class + +```typescript +export abstract class BaseProvider { + protected config: ProviderConfig; + + constructor(config: ProviderConfig = {}) { + this.config = config; + } + + /** + * Get provider name (e.g., "claude", "codex") + */ + abstract getName(): string; + + /** + * Execute a query and stream responses + */ + abstract executeQuery(options: ExecuteOptions): AsyncGenerator; + + /** + * Detect provider installation status + */ + abstract detectInstallation(): Promise; + + /** + * Get available models for this provider + */ + abstract getAvailableModels(): ModelDefinition[]; + + /** + * Check if provider supports a specific feature (optional) + */ + supportsFeature(feature: string): boolean { + return false; + } +} +``` + +### Shared Types + +**Location**: `apps/server/src/providers/types.ts` + +#### ExecuteOptions + +Input configuration for executing queries: + +```typescript +export interface ExecuteOptions { + prompt: string | Array<{ type: string; text?: string; source?: object }>; + model: string; + cwd: string; + systemPrompt?: string; + maxTurns?: number; + allowedTools?: string[]; + mcpServers?: Record; + abortController?: AbortController; + conversationHistory?: ConversationMessage[]; +} +``` + +#### ProviderMessage + +Output messages streamed from providers: + +```typescript +export interface ProviderMessage { + type: "assistant" | "user" | "error" | "result"; + subtype?: "success" | "error"; + message?: { + role: "user" | "assistant"; + content: ContentBlock[]; + }; + result?: string; + error?: string; +} +``` + +#### ContentBlock + +Individual content blocks in messages: + +```typescript +export interface ContentBlock { + type: "text" | "tool_use" | "thinking" | "tool_result"; + text?: string; + thinking?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; +} +``` + +--- + +## Available Providers + +### 1. Claude Provider (SDK-based) + +**Location**: `apps/server/src/providers/claude-provider.ts` + +Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration. + +#### Features + +- āœ… Native multi-turn conversation support +- āœ… Vision support (images) +- āœ… Tool use (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch) +- āœ… Thinking blocks (extended thinking) +- āœ… Streaming responses +- āœ… No CLI installation required (npm dependency) + +#### Model Detection + +Routes models that: +- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`) +- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"` + +#### Authentication + +Requires one of: +- `ANTHROPIC_API_KEY` environment variable +- `CLAUDE_CODE_OAUTH_TOKEN` environment variable + +#### Example Usage + +```typescript +const provider = new ClaudeProvider(); + +const stream = provider.executeQuery({ + prompt: "What is 2+2?", + model: "claude-opus-4-5-20251101", + cwd: "/project/path", + systemPrompt: "You are a helpful assistant.", + maxTurns: 20, + allowedTools: ["Read", "Write", "Bash"], + abortController: new AbortController(), + conversationHistory: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi! How can I help?" } + ] +}); + +for await (const msg of stream) { + if (msg.type === "assistant") { + console.log(msg.message?.content); + } +} +``` + +#### Conversation History Handling + +Uses `convertHistoryToMessages()` utility to convert history to SDK format: + +```typescript +const historyMessages = convertHistoryToMessages(conversationHistory); +for (const msg of historyMessages) { + yield msg; // Yield to SDK +} +``` + +--- + +### 2. Codex Provider (CLI-based) + +**Location**: `apps/server/src/providers/codex-provider.ts` + +Spawns OpenAI Codex CLI as a subprocess and converts JSONL output to provider format. + +#### Features + +- āœ… Subprocess execution (`codex exec --model --json --full-auto`) +- āœ… JSONL stream parsing +- āœ… Supports GPT-5.1/5.2 Codex models +- āœ… Vision support (GPT-5.1, GPT-5.2) +- āœ… Tool use via MCP servers +- āœ… Timeout detection (30s no output) +- āœ… Abort signal handling + +#### Model Detection + +Routes models that: +- Start with `"gpt-"` (e.g., `"gpt-5.2"`, `"gpt-5.1-codex-max"`) +- Start with `"o"` (e.g., `"o1"`, `"o1-mini"`) + +#### Available Models + +| Model | Description | Context | Max Output | Vision | +|-------|-------------|---------|------------|--------| +| `gpt-5.2` | Latest Codex model | 256K | 32K | Yes | +| `gpt-5.1-codex-max` | Maximum capability | 256K | 32K | Yes | +| `gpt-5.1-codex` | Standard Codex | 256K | 32K | Yes | +| `gpt-5.1-codex-mini` | Lightweight | 256K | 16K | No | +| `gpt-5.1` | General-purpose | 256K | 32K | Yes | + +#### Authentication + +Supports two methods: +1. **CLI login**: `codex login` (OAuth tokens stored in `~/.codex/auth.json`) +2. **API key**: `OPENAI_API_KEY` environment variable + +#### Installation Detection + +Uses `CodexCliDetector` to check: +- PATH for `codex` command +- npm global: `npm list -g @openai/codex` +- Homebrew (macOS): `/opt/homebrew/bin/codex` +- Common paths: `~/.local/bin/codex`, `/usr/local/bin/codex` + +#### Example Usage + +```typescript +const provider = new CodexProvider(); + +const stream = provider.executeQuery({ + prompt: "Fix the bug in main.ts", + model: "gpt-5.2", + cwd: "/project/path", + systemPrompt: "You are an expert TypeScript developer.", + abortController: new AbortController() +}); + +for await (const msg of stream) { + if (msg.type === "assistant") { + console.log(msg.message?.content); + } else if (msg.type === "error") { + console.error(msg.error); + } +} +``` + +#### JSONL Event Conversion + +Codex CLI outputs JSONL events that get converted to `ProviderMessage` format: + +| Codex Event | Provider Message | +|-------------|------------------| +| `item.completed` (reasoning) | `{ type: "assistant", content: [{ type: "thinking" }] }` | +| `item.completed` (agent_message) | `{ type: "assistant", content: [{ type: "text" }] }` | +| `item.completed` (command_execution) | `{ type: "assistant", content: [{ type: "text", text: "```bash\n...\n```" }] }` | +| `item.started` (command_execution) | `{ type: "assistant", content: [{ type: "tool_use" }] }` | +| `item.updated` (todo_list) | `{ type: "assistant", content: [{ type: "text", text: "**Updated Todo List:**..." }] }` | +| `thread.completed` | `{ type: "result", subtype: "success" }` | +| `error` | `{ type: "error", error: "..." }` | + +#### Conversation History Handling + +Uses `formatHistoryAsText()` utility to prepend history as text context (CLI doesn't support native multi-turn): + +```typescript +const historyText = formatHistoryAsText(conversationHistory); +combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`; +``` + +#### MCP Server Configuration + +**Location**: `apps/server/src/providers/codex-config-manager.ts` + +Manages TOML configuration for MCP servers: + +```typescript +await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath); +``` + +Generates `.codex/config.toml`: +```toml +[mcp_servers.automaker-tools] +command = "node" +args = ["/path/to/mcp-server.js"] +enabled_tools = ["UpdateFeatureStatus"] +``` + +--- + +## Provider Factory + +**Location**: `apps/server/src/providers/provider-factory.ts` + +Routes requests to the appropriate provider based on model string. + +### Model-Based Routing + +```typescript +export class ProviderFactory { + /** + * Get provider for a specific model + */ + static getProviderForModel(modelId: string): BaseProvider { + const lowerModel = modelId.toLowerCase(); + + // OpenAI/Codex models + if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) { + return new CodexProvider(); + } + + // Claude models + if (lowerModel.startsWith("claude-") || + ["haiku", "sonnet", "opus"].includes(lowerModel)) { + return new ClaudeProvider(); + } + + // Default to Claude + return new ClaudeProvider(); + } + + /** + * Check installation status of all providers + */ + static async checkAllProviders(): Promise> { + const claude = new ClaudeProvider(); + const codex = new CodexProvider(); + + return { + claude: await claude.detectInstallation(), + codex: await codex.detectInstallation(), + }; + } +} +``` + +### Usage in Services + +```typescript +import { ProviderFactory } from "../providers/provider-factory.js"; + +// In AgentService or AutoModeService +const provider = ProviderFactory.getProviderForModel(model); +const stream = provider.executeQuery(options); + +for await (const msg of stream) { + // Handle messages (format is consistent across all providers) +} +``` + +--- + +## Adding New Providers + +### Step 1: Create Provider File + +Create `apps/server/src/providers/[name]-provider.ts`: + +```typescript +import { BaseProvider } from "./base-provider.js"; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from "./types.js"; + +export class CursorProvider extends BaseProvider { + getName(): string { + return "cursor"; + } + + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Implementation here + // 1. Spawn cursor CLI or use SDK + // 2. Convert output to ProviderMessage format + // 3. Yield messages + } + + async detectInstallation(): Promise { + // Check if cursor is installed + // Return { installed: boolean, path?: string, version?: string } + } + + getAvailableModels(): ModelDefinition[] { + return [ + { + id: "cursor-premium", + name: "Cursor Premium", + modelString: "cursor-premium", + provider: "cursor", + description: "Cursor's premium model", + contextWindow: 200000, + maxOutputTokens: 8192, + supportsVision: true, + supportsTools: true, + tier: "premium", + default: true, + } + ]; + } + + supportsFeature(feature: string): boolean { + const supportedFeatures = ["tools", "text", "vision"]; + return supportedFeatures.includes(feature); + } +} +``` + +### Step 2: Add Routing in Factory + +Update `apps/server/src/providers/provider-factory.ts`: + +```typescript +import { CursorProvider } from "./cursor-provider.js"; + +static getProviderForModel(modelId: string): BaseProvider { + const lowerModel = modelId.toLowerCase(); + + // Cursor models + if (lowerModel.startsWith("cursor-")) { + return new CursorProvider(); + } + + // ... existing routing +} + +static async checkAllProviders() { + const cursor = new CursorProvider(); + + return { + claude: await claude.detectInstallation(), + codex: await codex.detectInstallation(), + cursor: await cursor.detectInstallation(), // NEW + }; +} +``` + +### Step 3: Update Models List + +Update `apps/server/src/routes/models.ts`: + +```typescript +{ + id: "cursor-premium", + name: "Cursor Premium", + provider: "cursor", + contextWindow: 200000, + maxOutputTokens: 8192, + supportsVision: true, + supportsTools: true, +} +``` + +### Step 4: Done! + +No changes needed in: +- āœ… AgentService +- āœ… AutoModeService +- āœ… Any business logic + +The provider architecture handles everything automatically. + +--- + +## Provider Types + +### SDK-Based Providers (like Claude) + +**Characteristics**: +- Direct SDK/library integration +- No subprocess spawning +- Native multi-turn support +- Streaming via async generators + +**Example**: ClaudeProvider using `@anthropic-ai/claude-agent-sdk` + +**Advantages**: +- Lower latency +- More control over options +- Easier error handling +- No CLI installation required + +--- + +### CLI-Based Providers (like Codex) + +**Characteristics**: +- Subprocess spawning +- JSONL stream parsing +- Text-based conversation history +- Requires CLI installation + +**Example**: CodexProvider using `codex exec --json` + +**Advantages**: +- Access to CLI-only features +- No SDK dependency +- Can use any CLI tool + +**Implementation Pattern**: +1. Use `spawnJSONLProcess()` from `subprocess-manager.ts` +2. Convert JSONL events to `ProviderMessage` format +3. Handle authentication (CLI login or API key) +4. Implement timeout detection + +--- + +## Best Practices + +### 1. Message Format Consistency + +All providers MUST output the same `ProviderMessage` format so services can handle them uniformly: + +```typescript +// āœ… Correct - Consistent format +yield { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Response" }] + } +}; + +// āŒ Incorrect - Provider-specific format +yield { + customType: "response", + data: "Response" +}; +``` + +### 2. Error Handling + +Always yield error messages, never throw: + +```typescript +// āœ… Correct +try { + // ... +} catch (error) { + yield { + type: "error", + error: (error as Error).message + }; + return; +} + +// āŒ Incorrect +throw new Error("Provider failed"); +``` + +### 3. Abort Signal Support + +Respect the abort controller: + +```typescript +if (abortController?.signal.aborted) { + yield { type: "error", error: "Operation cancelled" }; + return; +} +``` + +### 4. Conversation History + +- **SDK providers**: Use `convertHistoryToMessages()` and yield messages +- **CLI providers**: Use `formatHistoryAsText()` and prepend to prompt + +### 5. Image Handling + +- **Vision models**: Pass images as content blocks +- **Non-vision models**: Extract text only using utilities + +### 6. Logging + +Use consistent logging prefixes: + +```typescript +console.log(`[${this.getName()}Provider] Operation started`); +console.error(`[${this.getName()}Provider] Error:`, error); +``` + +### 7. Installation Detection + +Implement thorough detection: +- Check multiple installation methods +- Verify authentication +- Return detailed status + +### 8. Model Definitions + +Provide accurate model metadata: + +```typescript +{ + id: "model-id", + name: "Human-readable name", + modelString: "exact-model-string", + provider: "provider-name", + description: "What this model is good for", + contextWindow: 200000, + maxOutputTokens: 8192, + supportsVision: true, + supportsTools: true, + tier: "premium" | "standard" | "basic", + default: false +} +``` + +--- + +## Testing Providers + +### Unit Tests + +Test each provider method independently: + +```typescript +describe("ClaudeProvider", () => { + it("should detect installation", async () => { + const provider = new ClaudeProvider(); + const status = await provider.detectInstallation(); + + expect(status.installed).toBe(true); + expect(status.method).toBe("sdk"); + }); + + it("should stream messages correctly", async () => { + const provider = new ClaudeProvider(); + const messages = []; + + for await (const msg of provider.executeQuery(options)) { + messages.push(msg); + } + + expect(messages.length).toBeGreaterThan(0); + expect(messages[0].type).toBe("assistant"); + }); +}); +``` + +### Integration Tests + +Test provider interaction with services: + +```typescript +describe("Provider Integration", () => { + it("should work with AgentService", async () => { + const provider = ProviderFactory.getProviderForModel("claude-opus-4-5-20251101"); + + // Test full workflow + }); +}); +``` + +--- + +## Environment Variables + +### Claude Provider + +```bash +# Required (one of): +ANTHROPIC_API_KEY=sk-ant-... +CLAUDE_CODE_OAUTH_TOKEN=... +``` + +### Codex Provider + +```bash +# Required (one of): +OPENAI_API_KEY=sk-... +# OR run: codex login + +# Optional: +CODEX_CLI_PATH=/custom/path/to/codex +``` + +--- + +## Troubleshooting + +### Provider Not Found + +**Problem**: `ProviderFactory.getProviderForModel()` returns wrong provider + +**Solution**: Check model string prefix in factory routing + +### Authentication Errors + +**Problem**: Provider fails with auth error + +**Solution**: +1. Check environment variables +2. For CLI providers, verify CLI login status +3. Check `detectInstallation()` output + +### JSONL Parsing Errors (CLI providers) + +**Problem**: Failed to parse JSONL line + +**Solution**: +1. Check CLI output format +2. Verify JSON is valid +3. Add error handling for malformed lines + +### Timeout Issues (CLI providers) + +**Problem**: Subprocess hangs + +**Solution**: +1. Increase timeout in `spawnJSONLProcess` options +2. Check CLI process for hangs +3. Verify abort signal handling + +--- + +## Future Provider Ideas + +Potential providers to add: + +1. **Cursor Provider** (`cursor-*`) + - CLI-based + - Code completion specialist + +2. **OpenCode Provider** (`opencode-*`) + - SDK or CLI-based + - Open-source alternative + +3. **Gemini Provider** (`gemini-*`) + - Google's AI models + - SDK-based via `@google/generative-ai` + +4. **Ollama Provider** (`ollama-*`) + - Local model hosting + - CLI or HTTP API + +Each would follow the same pattern: +1. Create `[name]-provider.ts` implementing `BaseProvider` +2. Add routing in `provider-factory.ts` +3. Update models list +4. Done! āœ… diff --git a/docs/server/utilities.md b/docs/server/utilities.md new file mode 100644 index 00000000..d14aaf5f --- /dev/null +++ b/docs/server/utilities.md @@ -0,0 +1,672 @@ +# Server Utilities Reference + +This document describes all utility modules available in `apps/server/src/lib/`. These utilities provide reusable functionality for image handling, prompt building, model resolution, conversation management, and error handling. + +--- + +## Table of Contents + +1. [Image Handler](#image-handler) +2. [Prompt Builder](#prompt-builder) +3. [Model Resolver](#model-resolver) +4. [Conversation Utils](#conversation-utils) +5. [Error Handler](#error-handler) +6. [Subprocess Manager](#subprocess-manager) +7. [Events](#events) +8. [Auth](#auth) +9. [Security](#security) + +--- + +## Image Handler + +**Location**: `apps/server/src/lib/image-handler.ts` + +Centralized utilities for processing image files, including MIME type detection, base64 encoding, and content block generation for Claude SDK format. + +### Functions + +#### `getMimeTypeForImage(imagePath: string): string` + +Get MIME type for an image file based on its extension. + +**Supported formats**: +- `.jpg`, `.jpeg` → `image/jpeg` +- `.png` → `image/png` +- `.gif` → `image/gif` +- `.webp` → `image/webp` +- Default: `image/png` + +**Example**: +```typescript +import { getMimeTypeForImage } from "../lib/image-handler.js"; + +const mimeType = getMimeTypeForImage("/path/to/image.jpg"); +// Returns: "image/jpeg" +``` + +--- + +#### `readImageAsBase64(imagePath: string): Promise` + +Read an image file and convert to base64 with metadata. + +**Returns**: `ImageData` +```typescript +interface ImageData { + base64: string; // Base64-encoded image data + mimeType: string; // MIME type + filename: string; // File basename + originalPath: string; // Original file path +} +``` + +**Example**: +```typescript +const imageData = await readImageAsBase64("/path/to/photo.png"); +console.log(imageData.base64); // "iVBORw0KG..." +console.log(imageData.mimeType); // "image/png" +console.log(imageData.filename); // "photo.png" +``` + +--- + +#### `convertImagesToContentBlocks(imagePaths: string[], workDir?: string): Promise` + +Convert image paths to content blocks in Claude SDK format. Handles both relative and absolute paths. + +**Parameters**: +- `imagePaths` - Array of image file paths +- `workDir` - Optional working directory for resolving relative paths + +**Returns**: Array of `ImageContentBlock` +```typescript +interface ImageContentBlock { + type: "image"; + source: { + type: "base64"; + media_type: string; + data: string; + }; +} +``` + +**Example**: +```typescript +const imageBlocks = await convertImagesToContentBlocks( + ["./screenshot.png", "/absolute/path/diagram.jpg"], + "/project/root" +); + +// Use in prompt content +const contentBlocks = [ + { type: "text", text: "Analyze these images:" }, + ...imageBlocks +]; +``` + +--- + +#### `formatImagePathsForPrompt(imagePaths: string[]): string` + +Format image paths as a bulleted list for inclusion in text prompts. + +**Returns**: Formatted string with image paths, or empty string if no images. + +**Example**: +```typescript +const pathsList = formatImagePathsForPrompt([ + "/screenshots/login.png", + "/diagrams/architecture.png" +]); + +// Returns: +// "\n\nAttached images:\n- /screenshots/login.png\n- /diagrams/architecture.png\n" +``` + +--- + +## Prompt Builder + +**Location**: `apps/server/src/lib/prompt-builder.ts` + +Standardized prompt building that combines text prompts with image attachments. + +### Functions + +#### `buildPromptWithImages(basePrompt: string, imagePaths?: string[], workDir?: string, includeImagePaths: boolean = false): Promise` + +Build a prompt with optional image attachments. + +**Parameters**: +- `basePrompt` - The text prompt +- `imagePaths` - Optional array of image file paths +- `workDir` - Optional working directory for resolving relative paths +- `includeImagePaths` - Whether to append image paths to the text (default: false) + +**Returns**: `PromptWithImages` +```typescript +interface PromptWithImages { + content: PromptContent; // string | Array + hasImages: boolean; +} + +type PromptContent = string | Array<{ + type: string; + text?: string; + source?: object; +}>; +``` + +**Example**: +```typescript +import { buildPromptWithImages } from "../lib/prompt-builder.js"; + +// Without images +const { content } = await buildPromptWithImages("What is 2+2?"); +// content: "What is 2+2?" (simple string) + +// With images +const { content, hasImages } = await buildPromptWithImages( + "Analyze this screenshot", + ["/path/to/screenshot.png"], + "/project/root", + true // include image paths in text +); +// content: [ +// { type: "text", text: "Analyze this screenshot\n\nAttached images:\n- /path/to/screenshot.png\n" }, +// { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } } +// ] +// hasImages: true +``` + +**Use Cases**: +- **AgentService**: Set `includeImagePaths: true` to list paths for Read tool access +- **AutoModeService**: Set `includeImagePaths: false` to avoid duplication in feature descriptions + +--- + +## Model Resolver + +**Location**: `apps/server/src/lib/model-resolver.ts` + +Centralized model string mapping and resolution for handling model aliases and provider detection. + +### Constants + +#### `CLAUDE_MODEL_MAP` + +Model alias mapping for Claude models. + +```typescript +export const CLAUDE_MODEL_MAP: Record = { + haiku: "claude-haiku-4-5", + sonnet: "claude-sonnet-4-20250514", + opus: "claude-opus-4-5-20251101", +} as const; +``` + +#### `DEFAULT_MODELS` + +Default models per provider. + +```typescript +export const DEFAULT_MODELS = { + claude: "claude-opus-4-5-20251101", + openai: "gpt-5.2", +} as const; +``` + +### Functions + +#### `resolveModelString(modelKey?: string, defaultModel: string = DEFAULT_MODELS.claude): string` + +Resolve a model key/alias to a full model string. + +**Logic**: +1. If `modelKey` is undefined → return `defaultModel` +2. If starts with `"gpt-"` or `"o"` → pass through (OpenAI/Codex model) +3. If includes `"claude-"` → pass through (full Claude model string) +4. If in `CLAUDE_MODEL_MAP` → return mapped value +5. Otherwise → return `defaultModel` with warning + +**Example**: +```typescript +import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; + +resolveModelString("opus"); +// Returns: "claude-opus-4-5-20251101" +// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101"" + +resolveModelString("gpt-5.2"); +// Returns: "gpt-5.2" +// Logs: "[ModelResolver] Using OpenAI/Codex model: gpt-5.2" + +resolveModelString("claude-sonnet-4-20250514"); +// Returns: "claude-sonnet-4-20250514" +// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514" + +resolveModelString("invalid-model"); +// Returns: "claude-opus-4-5-20251101" +// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101"" +``` + +--- + +#### `getEffectiveModel(explicitModel?: string, sessionModel?: string, defaultModel?: string): string` + +Get the effective model from multiple sources with priority. + +**Priority**: explicit model > session model > default model + +**Example**: +```typescript +import { getEffectiveModel } from "../lib/model-resolver.js"; + +// Explicit model takes precedence +getEffectiveModel("sonnet", "opus"); +// Returns: "claude-sonnet-4-20250514" + +// Falls back to session model +getEffectiveModel(undefined, "haiku"); +// Returns: "claude-haiku-4-5" + +// Falls back to default +getEffectiveModel(undefined, undefined, "gpt-5.2"); +// Returns: "gpt-5.2" +``` + +--- + +## Conversation Utils + +**Location**: `apps/server/src/lib/conversation-utils.ts` + +Standardized conversation history processing for both SDK-based and CLI-based providers. + +### Types + +```typescript +import type { ConversationMessage } from "../providers/types.js"; + +interface ConversationMessage { + role: "user" | "assistant"; + content: string | Array<{ type: string; text?: string; source?: object }>; +} +``` + +### Functions + +#### `extractTextFromContent(content: string | Array): string` + +Extract plain text from message content (handles both string and array formats). + +**Example**: +```typescript +import { extractTextFromContent } from "../lib/conversation-utils.js"; + +// String content +extractTextFromContent("Hello world"); +// Returns: "Hello world" + +// Array content +extractTextFromContent([ + { type: "text", text: "Hello" }, + { type: "image", source: {...} }, + { type: "text", text: "world" } +]); +// Returns: "Hello\nworld" +``` + +--- + +#### `normalizeContentBlocks(content: string | Array): Array` + +Normalize message content to array format. + +**Example**: +```typescript +// String → array +normalizeContentBlocks("Hello"); +// Returns: [{ type: "text", text: "Hello" }] + +// Array → pass through +normalizeContentBlocks([{ type: "text", text: "Hello" }]); +// Returns: [{ type: "text", text: "Hello" }] +``` + +--- + +#### `formatHistoryAsText(history: ConversationMessage[]): string` + +Format conversation history as plain text for CLI-based providers (e.g., Codex). + +**Returns**: Formatted text with role labels, or empty string if no history. + +**Example**: +```typescript +const history = [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "2+2 equals 4." } +]; + +const formatted = formatHistoryAsText(history); +// Returns: +// "Previous conversation: +// +// User: What is 2+2? +// +// Assistant: 2+2 equals 4. +// +// --- +// +// " +``` + +--- + +#### `convertHistoryToMessages(history: ConversationMessage[]): Array` + +Convert conversation history to Claude SDK message format. + +**Returns**: Array of SDK-formatted messages ready to yield in async generator. + +**Example**: +```typescript +const history = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" } +]; + +const messages = convertHistoryToMessages(history); +// Returns: +// [ +// { +// type: "user", +// session_id: "", +// message: { +// role: "user", +// content: [{ type: "text", text: "Hello" }] +// }, +// parent_tool_use_id: null +// }, +// { +// type: "assistant", +// session_id: "", +// message: { +// role: "assistant", +// content: [{ type: "text", text: "Hi there!" }] +// }, +// parent_tool_use_id: null +// } +// ] +``` + +--- + +## Error Handler + +**Location**: `apps/server/src/lib/error-handler.ts` + +Standardized error classification and handling utilities. + +### Types + +```typescript +export type ErrorType = "authentication" | "abort" | "execution" | "unknown"; + +export interface ErrorInfo { + type: ErrorType; + message: string; + isAbort: boolean; + isAuth: boolean; + originalError: unknown; +} +``` + +### Functions + +#### `isAbortError(error: unknown): boolean` + +Check if an error is an abort/cancellation error. + +**Example**: +```typescript +import { isAbortError } from "../lib/error-handler.js"; + +try { + // ... operation +} catch (error) { + if (isAbortError(error)) { + console.log("Operation was cancelled"); + return { success: false, aborted: true }; + } +} +``` + +--- + +#### `isAuthenticationError(errorMessage: string): boolean` + +Check if an error is an authentication/API key error. + +**Detects**: +- "Authentication failed" +- "Invalid API key" +- "authentication_failed" +- "Fix external API key" + +**Example**: +```typescript +if (isAuthenticationError(error.message)) { + console.error("Please check your API key configuration"); +} +``` + +--- + +#### `classifyError(error: unknown): ErrorInfo` + +Classify an error into a specific type. + +**Example**: +```typescript +import { classifyError } from "../lib/error-handler.js"; + +try { + // ... operation +} catch (error) { + const errorInfo = classifyError(error); + + switch (errorInfo.type) { + case "authentication": + // Handle auth errors + break; + case "abort": + // Handle cancellation + break; + case "execution": + // Handle other errors + break; + } +} +``` + +--- + +#### `getUserFriendlyErrorMessage(error: unknown): string` + +Get a user-friendly error message. + +**Example**: +```typescript +try { + // ... operation +} catch (error) { + const friendlyMessage = getUserFriendlyErrorMessage(error); + // "Operation was cancelled" for abort errors + // "Authentication failed. Please check your API key." for auth errors + // Original error message for other errors +} +``` + +--- + +## Subprocess Manager + +**Location**: `apps/server/src/lib/subprocess-manager.ts` + +Utilities for spawning CLI processes and parsing JSONL streams (used by Codex provider). + +### Types + +```typescript +export interface SubprocessOptions { + command: string; + args: string[]; + cwd: string; + env?: Record; + abortController?: AbortController; + timeout?: number; // Milliseconds of no output before timeout +} + +export interface SubprocessResult { + stdout: string; + stderr: string; + exitCode: number | null; +} +``` + +### Functions + +#### `async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator` + +Spawns a subprocess and streams JSONL output line-by-line. + +**Features**: +- Parses each line as JSON +- Handles abort signals +- 30-second timeout detection for hanging processes +- Collects stderr for error reporting +- Continues processing other lines if one fails to parse + +**Example**: +```typescript +import { spawnJSONLProcess } from "../lib/subprocess-manager.js"; + +const stream = spawnJSONLProcess({ + command: "codex", + args: ["exec", "--model", "gpt-5.2", "--json", "--full-auto", "Fix the bug"], + cwd: "/project/path", + env: { OPENAI_API_KEY: "sk-..." }, + abortController: new AbortController(), + timeout: 30000 +}); + +for await (const event of stream) { + console.log("Received event:", event); + // Process JSONL events +} +``` + +--- + +#### `async function spawnProcess(options: SubprocessOptions): Promise` + +Spawns a subprocess and collects all output. + +**Example**: +```typescript +const result = await spawnProcess({ + command: "git", + args: ["status"], + cwd: "/project/path" +}); + +console.log(result.stdout); // Git status output +console.log(result.exitCode); // 0 for success +``` + +--- + +## Events + +**Location**: `apps/server/src/lib/events.ts` + +Event emitter system for WebSocket communication. + +**Documented separately** - see existing codebase for event types and usage. + +--- + +## Auth + +**Location**: `apps/server/src/lib/auth.ts` + +Authentication utilities for API endpoints. + +**Documented separately** - see existing codebase for authentication flow. + +--- + +## Security + +**Location**: `apps/server/src/lib/security.ts` + +Security utilities for input validation and sanitization. + +**Documented separately** - see existing codebase for security patterns. + +--- + +## Best Practices + +### When to Use Which Utility + +1. **Image handling** → Always use `image-handler.ts` utilities + - āœ… Do: `convertImagesToContentBlocks(imagePaths, workDir)` + - āŒ Don't: Manually read files and encode base64 + +2. **Prompt building** → Use `prompt-builder.ts` for consistency + - āœ… Do: `buildPromptWithImages(text, images, workDir, includePathsInText)` + - āŒ Don't: Manually construct content block arrays + +3. **Model resolution** → Use `model-resolver.ts` for all model handling + - āœ… Do: `resolveModelString(feature.model, DEFAULT_MODELS.claude)` + - āŒ Don't: Inline model mapping logic + +4. **Error handling** → Use `error-handler.ts` for classification + - āœ… Do: `if (isAbortError(error)) { ... }` + - āŒ Don't: `if (error instanceof AbortError || error.name === "AbortError") { ... }` + +### Importing Utilities + +Always use `.js` extension in imports for ESM compatibility: + +```typescript +// āœ… Correct +import { buildPromptWithImages } from "../lib/prompt-builder.js"; + +// āŒ Incorrect +import { buildPromptWithImages } from "../lib/prompt-builder"; +``` + +--- + +## Testing Utilities + +When writing tests for utilities: + +1. **Unit tests** - Test each function in isolation +2. **Integration tests** - Test utilities working together +3. **Mock external dependencies** - File system, child processes + +Example: +```typescript +describe("image-handler", () => { + it("should detect MIME type correctly", () => { + expect(getMimeTypeForImage("photo.jpg")).toBe("image/jpeg"); + expect(getMimeTypeForImage("diagram.png")).toBe("image/png"); + }); +}); +``` From d08eba233167b52feab95c92be1f943ad63b0c0c Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 04:30:13 +0100 Subject: [PATCH 05/47] fix: resolve TypeScript compilation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed 4 TypeScript errors: - fs.ts: Removed duplicate 'os' import (lines 8 and 10) - spec-regeneration.ts: Removed dead code checking for impossible error type (2 occurrences) The error type checks were comparing msg.type to "error", but the SDK type union does not include "error" as a valid message type. Errors are properly handled in the catch blocks, so these checks were unreachable dead code. All TypeScript compilation now passes cleanly. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/routes/fs.ts | 1 - apps/server/src/routes/spec-regeneration.ts | 6 ------ 2 files changed, 7 deletions(-) diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index ac492f03..5250e8f0 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -7,7 +7,6 @@ import { Router, type Request, type Response } from "express"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import os from "os"; import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; import type { EventEmitter } from "../lib/events.js"; diff --git a/apps/server/src/routes/spec-regeneration.ts b/apps/server/src/routes/spec-regeneration.ts index c9373675..67df6c5b 100644 --- a/apps/server/src/routes/spec-regeneration.ts +++ b/apps/server/src/routes/spec-regeneration.ts @@ -355,9 +355,6 @@ Format your response as markdown. Be specific and actionable.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { - console.error("[SpecRegeneration] āŒ Received error message from stream:"); - console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } } } catch (streamError) { @@ -505,9 +502,6 @@ Generate 5-15 features that build on each other logically.`; } else if (msg.type === "result" && (msg as any).subtype === "success") { console.log("[SpecRegeneration] Received success result for features"); responseText = (msg as any).result || responseText; - } else if (msg.type === "error") { - console.error("[SpecRegeneration] āŒ Received error message from feature stream:"); - console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2)); } } } catch (streamError) { From 2f2eab6e0279247e57da788573747c3a5feffe42 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 13 Dec 2025 04:37:53 +0100 Subject: [PATCH 06/47] refactor: update auto-mode-service to use dynamic model resolution - Replaced hardcoded model string with dynamic resolution for the analysis model, allowing for future flexibility. - Enhanced error handling to provide specific authentication failure messages based on the model type, improving user feedback. This change streamlines the model selection process and improves error clarity for users. --- apps/server/src/services/auto-mode-service.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8d84b66e..5d3385b8 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -639,11 +639,13 @@ Address the follow-up instructions above. Review the previous work and make the Format your response as a structured markdown document.`; try { - const provider = ProviderFactory.getProviderForModel("claude-sonnet-4-20250514"); + // Use default Claude model for analysis (can be overridden in the future) + const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); + const provider = ProviderFactory.getProviderForModel(analysisModel); const options: ExecuteOptions = { prompt, - model: "claude-sonnet-4-20250514", + model: analysisModel, maxTurns: 5, cwd: projectPath, allowedTools: ["Read", "Glob", "Grep"], @@ -926,10 +928,13 @@ When done, summarize what you implemented and any notes for the developer.`; if (block.text && (block.text.includes("Invalid API key") || block.text.includes("authentication_failed") || block.text.includes("Fix external API key"))) { - throw new Error( - "Authentication failed: Invalid or expired API key. " + - "Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate." - ); + const isCodex = finalModel.startsWith("gpt-") || finalModel.startsWith("o"); + const errorMsg = isCodex + ? "Authentication failed: Invalid or expired API key. " + + "Please check your OPENAI_API_KEY or run 'codex login' to re-authenticate." + : "Authentication failed: Invalid or expired API key. " + + "Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."; + throw new Error(errorMsg); } this.emitAutoModeEvent("auto_mode_progress", { From 25f5f7d6b2ab22bd3b0df49fe39e513b43dab8ac Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 13 Dec 2025 04:42:08 +0100 Subject: [PATCH 07/47] Update apps/app/src/store/app-store.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/app/src/store/app-store.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 76b552d8..972b9b55 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -1120,10 +1120,9 @@ export const useAppStore = create()( resetAIProfiles: () => { // Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults - const currentProfiles = get().aiProfiles; - const userProfiles = currentProfiles.filter((p) => !p.isBuiltIn); - const mergedProfiles = [...DEFAULT_AI_PROFILES, ...userProfiles]; - set({ aiProfiles: mergedProfiles }); + const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map(p => p.id)); + const userProfiles = get().aiProfiles.filter(p => !p.isBuiltIn && !defaultProfileIds.has(p.id)); + set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] }); }, // Project Analysis actions From 05910905ee6f25e31e9979f318f96703ee0bbd51 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 17:14:31 -0500 Subject: [PATCH 08/47] adding new project from template --- apps/app/src/components/new-project-modal.tsx | 424 ++++++++++++++++++ .../app/src/components/views/welcome-view.tsx | 333 +++++++++----- apps/app/src/lib/http-api-client.ts | 10 + apps/app/src/lib/templates.ts | 62 +++ apps/server/src/index.ts | 2 + apps/server/src/routes/templates.ts | 171 +++++++ 6 files changed, 892 insertions(+), 110 deletions(-) create mode 100644 apps/app/src/components/new-project-modal.tsx create mode 100644 apps/app/src/lib/templates.ts create mode 100644 apps/server/src/routes/templates.ts diff --git a/apps/app/src/components/new-project-modal.tsx b/apps/app/src/components/new-project-modal.tsx new file mode 100644 index 00000000..562363c4 --- /dev/null +++ b/apps/app/src/components/new-project-modal.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + FolderPlus, + Rocket, + ExternalLink, + Check, + Loader2, + Link, + Folder, +} from "lucide-react"; +import { starterTemplates, type StarterTemplate } from "@/lib/templates"; +import { getElectronAPI } from "@/lib/electron"; +import { getHttpApiClient } from "@/lib/http-api-client"; +import { cn } from "@/lib/utils"; + +interface ValidationErrors { + projectName?: boolean; + workspaceDir?: boolean; + templateSelection?: boolean; + customUrl?: boolean; +} + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateBlankProject: (projectName: string, parentDir: string) => Promise; + onCreateFromTemplate: ( + template: StarterTemplate, + projectName: string, + parentDir: string + ) => Promise; + onCreateFromCustomUrl: ( + repoUrl: string, + projectName: string, + parentDir: string + ) => Promise; + isCreating: boolean; +} + +export function NewProjectModal({ + open, + onOpenChange, + onCreateBlankProject, + onCreateFromTemplate, + onCreateFromCustomUrl, + isCreating, +}: NewProjectModalProps) { + const [activeTab, setActiveTab] = useState<"blank" | "template">("blank"); + const [projectName, setProjectName] = useState(""); + const [workspaceDir, setWorkspaceDir] = useState(""); + const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [useCustomUrl, setUseCustomUrl] = useState(false); + const [customUrl, setCustomUrl] = useState(""); + const [errors, setErrors] = useState({}); + + // Fetch workspace directory when modal opens + useEffect(() => { + if (open) { + setIsLoadingWorkspace(true); + const httpClient = getHttpApiClient(); + httpClient.workspace.getConfig() + .then((result) => { + if (result.success && result.workspaceDir) { + setWorkspaceDir(result.workspaceDir); + } + }) + .catch((error) => { + console.error("Failed to get workspace config:", error); + }) + .finally(() => { + setIsLoadingWorkspace(false); + }); + } + }, [open]); + + // Reset form when modal closes + useEffect(() => { + if (!open) { + setProjectName(""); + setSelectedTemplate(null); + setUseCustomUrl(false); + setCustomUrl(""); + setActiveTab("blank"); + setErrors({}); + } + }, [open]); + + // Clear specific errors when user fixes them + useEffect(() => { + if (projectName && errors.projectName) { + setErrors((prev) => ({ ...prev, projectName: false })); + } + }, [projectName, errors.projectName]); + + useEffect(() => { + if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) { + setErrors((prev) => ({ ...prev, templateSelection: false })); + } + }, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]); + + useEffect(() => { + if (customUrl && errors.customUrl) { + setErrors((prev) => ({ ...prev, customUrl: false })); + } + }, [customUrl, errors.customUrl]); + + const validateAndCreate = async () => { + const newErrors: ValidationErrors = {}; + + // Check project name + if (!projectName.trim()) { + newErrors.projectName = true; + } + + // Check workspace dir + if (!workspaceDir) { + newErrors.workspaceDir = true; + } + + // Check template selection (only for template tab) + if (activeTab === "template") { + if (useCustomUrl) { + if (!customUrl.trim()) { + newErrors.customUrl = true; + } + } else if (!selectedTemplate) { + newErrors.templateSelection = true; + } + } + + // If there are errors, show them and don't proceed + if (Object.values(newErrors).some(Boolean)) { + setErrors(newErrors); + return; + } + + // Clear errors and proceed + setErrors({}); + + if (activeTab === "blank") { + await onCreateBlankProject(projectName, workspaceDir); + } else if (useCustomUrl && customUrl) { + await onCreateFromCustomUrl(customUrl, projectName, workspaceDir); + } else if (selectedTemplate) { + await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir); + } + }; + + const handleOpenRepo = (url: string) => { + const api = getElectronAPI(); + api.openExternalLink(url); + }; + + const handleSelectTemplate = (template: StarterTemplate) => { + setSelectedTemplate(template); + setUseCustomUrl(false); + setCustomUrl(""); + }; + + const handleToggleCustomUrl = () => { + setUseCustomUrl(!useCustomUrl); + if (!useCustomUrl) { + setSelectedTemplate(null); + } + }; + + const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; + + return ( + + + + Create New Project + + Start with a blank project or choose from a starter template. + + + + {/* Project Name Input - Always visible at top */} +
+
+ + setProjectName(e.target.value)} + className={cn( + "bg-input text-foreground placeholder:text-muted-foreground", + errors.projectName + ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" + : "border-border" + )} + data-testid="project-name-input" + autoFocus + /> + {errors.projectName && ( +

Project name is required

+ )} +
+ + {/* Workspace Directory Display */} +
+ + + {isLoadingWorkspace ? ( + "Loading workspace..." + ) : workspaceDir ? ( + <>Will be created at: {projectPath || "..."} + ) : ( + No workspace configured - please configure WORKSPACE_DIR + )} + +
+
+ + setActiveTab(v as "blank" | "template")} + className="flex-1 flex flex-col overflow-hidden" + > + + + + Blank Project + + + + Starter Kit + + + +
+ +
+

+ Create an empty project with the standard .automaker directory + structure. Perfect for starting from scratch or importing an + existing codebase. +

+
+
+ + +
+ {/* Error message for template selection */} + {errors.templateSelection && ( +

Please select a template or enter a custom GitHub URL

+ )} + + {/* Preset Templates */} +
+ {starterTemplates.map((template) => ( +
handleSelectTemplate(template)} + data-testid={`template-${template.id}`} + > +
+
+
+

+ {template.name} +

+ {selectedTemplate?.id === template.id && !useCustomUrl && ( + + )} +
+

+ {template.description} +

+ + {/* Tech Stack */} +
+ {template.techStack.slice(0, 6).map((tech) => ( + + {tech} + + ))} + {template.techStack.length > 6 && ( + + +{template.techStack.length - 6} more + + )} +
+ + {/* Key Features */} +
+ Features: + {template.features.slice(0, 3).join(" Ā· ")} + {template.features.length > 3 && + ` Ā· +${template.features.length - 3} more`} +
+
+ + +
+
+ ))} + + {/* Custom URL Option */} +
+
+ +

Custom GitHub URL

+ {useCustomUrl && } +
+

+ Clone any public GitHub repository as a starting point. +

+ + {useCustomUrl && ( +
e.stopPropagation()} className="space-y-1"> + setCustomUrl(e.target.value)} + className={cn( + "bg-input text-foreground placeholder:text-muted-foreground", + errors.customUrl + ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" + : "border-border" + )} + data-testid="custom-url-input" + /> + {errors.customUrl && ( +

GitHub URL is required

+ )} +
+ )} +
+
+
+
+
+
+ + + + + {isCreating ? ( + <> + + {activeTab === "template" ? "Cloning..." : "Creating..."} + + ) : ( + <>Create Project + )} + + +
+
+ ); +} diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 9128c179..bd161a9e 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -2,9 +2,6 @@ import { useState, useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { HotkeyButton } from "@/components/ui/hotkey-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, @@ -13,13 +10,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI, type Project } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; @@ -41,14 +31,14 @@ import { } from "@/components/ui/dropdown-menu"; import { toast } from "sonner"; import { WorkspacePickerModal } from "@/components/workspace-picker-modal"; +import { NewProjectModal } from "@/components/new-project-modal"; import { getHttpApiClient } from "@/lib/http-api-client"; +import type { StarterTemplate } from "@/lib/templates"; export function WelcomeView() { const { projects, addProject, setCurrentProject, setCurrentView } = useAppStore(); - const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(""); - const [newProjectPath, setNewProjectPath] = useState(""); + const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isOpening, setIsOpening] = useState(false); const [showInitDialog, setShowInitDialog] = useState(false); @@ -231,31 +221,21 @@ export function WelcomeView() { ); const handleNewProject = () => { - setNewProjectName(""); - setNewProjectPath(""); - setShowNewProjectDialog(true); + setShowNewProjectModal(true); }; const handleInteractiveMode = () => { setCurrentView("interview"); }; - const handleSelectDirectory = async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); - - if (!result.canceled && result.filePaths[0]) { - setNewProjectPath(result.filePaths[0]); - } - }; - - const handleCreateProject = async () => { - if (!newProjectName || !newProjectPath) return; - + /** + * Create a blank project with just .automaker directory structure + */ + const handleCreateBlankProject = async (projectName: string, parentDir: string) => { setIsCreating(true); try { const api = getElectronAPI(); - const projectPath = `${newProjectPath}/${newProjectName}`; + const projectPath = `${parentDir}/${projectName}`; // Create project directory await api.mkdir(projectPath); @@ -274,7 +254,7 @@ export function WelcomeView() { await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` - ${newProjectName} + ${projectName} Describe your project here. This file will be analyzed by an AI agent @@ -297,24 +277,24 @@ export function WelcomeView() { const project = { id: `project-${Date.now()}`, - name: newProjectName, + name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }; addProject(project); setCurrentProject(project); - setShowNewProjectDialog(false); + setShowNewProjectModal(false); toast.success("Project created", { - description: `Created ${newProjectName} with .automaker directory`, + description: `Created ${projectName} with .automaker directory`, }); // Set init status to show the dialog setInitStatus({ isNewProject: true, createdFiles: initResult.createdFiles || [], - projectName: newProjectName, + projectName: projectName, projectPath: projectPath, }); setShowInitDialog(true); @@ -328,6 +308,206 @@ export function WelcomeView() { } }; + /** + * Create a project from a GitHub starter template + */ + const handleCreateFromTemplate = async ( + template: StarterTemplate, + projectName: string, + parentDir: string + ) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const api = getElectronAPI(); + + // Clone the template repository + const cloneResult = await httpClient.templates.clone( + template.repoUrl, + projectName, + parentDir + ); + + if (!cloneResult.success || !cloneResult.projectPath) { + toast.error("Failed to clone template", { + description: cloneResult.error || "Unknown error occurred", + }); + return; + } + + const projectPath = cloneResult.projectPath; + + // Initialize .automaker directory with all necessary files + const initResult = await initializeProject(projectPath); + + if (!initResult.success) { + toast.error("Failed to initialize project", { + description: initResult.error || "Unknown error occurred", + }); + return; + } + + // Update the app_spec.txt with template-specific info + await api.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was created from the "${template.name}" starter template. + ${template.description} + + + + ${template.techStack.map((tech) => `${tech}`).join("\n ")} + + + + ${template.features.map((feature) => `${feature}`).join("\n ")} + + + + + +` + ); + + const project = { + id: `project-${Date.now()}`, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + addProject(project); + setCurrentProject(project); + setShowNewProjectModal(false); + + toast.success("Project created from template", { + description: `Created ${projectName} from ${template.name}`, + }); + + // Set init status to show the dialog + setInitStatus({ + isNewProject: true, + createdFiles: initResult.createdFiles || [], + projectName: projectName, + projectPath: projectPath, + }); + setShowInitDialog(true); + + // Kick off project analysis + analyzeProject(projectPath); + } catch (error) { + console.error("Failed to create project from template:", error); + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsCreating(false); + } + }; + + /** + * Create a project from a custom GitHub URL + */ + const handleCreateFromCustomUrl = async ( + repoUrl: string, + projectName: string, + parentDir: string + ) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const api = getElectronAPI(); + + // Clone the repository + const cloneResult = await httpClient.templates.clone( + repoUrl, + projectName, + parentDir + ); + + if (!cloneResult.success || !cloneResult.projectPath) { + toast.error("Failed to clone repository", { + description: cloneResult.error || "Unknown error occurred", + }); + return; + } + + const projectPath = cloneResult.projectPath; + + // Initialize .automaker directory with all necessary files + const initResult = await initializeProject(projectPath); + + if (!initResult.success) { + toast.error("Failed to initialize project", { + description: initResult.error || "Unknown error occurred", + }); + return; + } + + // Update the app_spec.txt with basic info + await api.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was cloned from ${repoUrl}. + The AI agent will analyze the project structure. + + + + + + + + + + + + + +` + ); + + const project = { + id: `project-${Date.now()}`, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + addProject(project); + setCurrentProject(project); + setShowNewProjectModal(false); + + toast.success("Project created from repository", { + description: `Created ${projectName} from ${repoUrl}`, + }); + + // Set init status to show the dialog + setInitStatus({ + isNewProject: true, + createdFiles: initResult.createdFiles || [], + projectName: projectName, + projectPath: projectPath, + }); + setShowInitDialog(true); + + // Kick off project analysis + analyzeProject(projectPath); + } catch (error) { + console.error("Failed to create project from custom URL:", error); + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsCreating(false); + } + }; + const recentProjects = [...projects] .sort((a, b) => { const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0; @@ -508,82 +688,15 @@ export function WelcomeView() {
- {/* New Project Dialog */} - - - - - Create New Project - - - Set up a new project directory with initial configuration files. - - -
-
- - setNewProjectName(e.target.value)} - className="bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="project-name-input" - /> -
-
- -
- setNewProjectPath(e.target.value)} - className="flex-1 bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="project-path-input" - /> - -
-
-
- - - - {isCreating ? "Creating..." : "Create Project"} - - -
-
+ {/* New Project Modal */} + {/* Project Initialization Dialog */} diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 76313ee1..04c84bcd 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -668,6 +668,16 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Templates API + templates = { + clone: (repoUrl: string, projectName: string, parentDir: string): Promise<{ + success: boolean; + projectPath?: string; + projectName?: string; + error?: string; + }> => this.post("/api/templates/clone", { repoUrl, projectName, parentDir }), + }; + // Sessions API sessions = { list: (includeArchived?: boolean): Promise<{ diff --git a/apps/app/src/lib/templates.ts b/apps/app/src/lib/templates.ts new file mode 100644 index 00000000..b445895a --- /dev/null +++ b/apps/app/src/lib/templates.ts @@ -0,0 +1,62 @@ +/** + * Starter Kit Templates + * + * Define GitHub templates that users can clone when creating new projects. + */ + +export interface StarterTemplate { + id: string; + name: string; + description: string; + repoUrl: string; + techStack: string[]; + features: string[]; + category: "fullstack" | "frontend" | "backend" | "ai" | "other"; + author: string; +} + +export const starterTemplates: StarterTemplate[] = [ + { + id: "agentic-jumpstart", + name: "Agentic Jumpstart", + description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.", + repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit", + techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"], + features: [ + "Pre-configured VS Code settings", + "Docker Compose setup", + "Database migrations with Drizzle", + "Type-safe development", + "Environment setup with .env.example" + ], + category: "ai", + author: "webdevcody" + }, + { + id: "full-stack-campus", + name: "Full Stack Campus", + description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.", + repoUrl: "https://github.com/webdevcody/full-stack-campus", + techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"], + features: [ + "Community posts with comments and reactions", + "User profiles and portfolios", + "Calendar event management", + "Direct messaging", + "Member discovery directory", + "Real-time notifications", + "Tiered subscriptions (free/basic/pro)", + "File uploads with presigned URLs" + ], + category: "fullstack", + author: "webdevcody" + } +]; + +export function getTemplateById(id: string): StarterTemplate | undefined { + return starterTemplates.find(t => t.id === id); +} + +export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] { + return starterTemplates.filter(t => t.category === category); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 90238848..fa485bd5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -29,6 +29,7 @@ import { createModelsRoutes } from "./routes/models.js"; import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js"; import { createRunningAgentsRoutes } from "./routes/running-agents.js"; import { createWorkspaceRoutes } from "./routes/workspace.js"; +import { createTemplatesRoutes } from "./routes/templates.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; @@ -112,6 +113,7 @@ app.use("/api/models", createModelsRoutes()); app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); app.use("/api/running-agents", createRunningAgentsRoutes()); app.use("/api/workspace", createWorkspaceRoutes()); +app.use("/api/templates", createTemplatesRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/templates.ts b/apps/server/src/routes/templates.ts new file mode 100644 index 00000000..3dd27bd2 --- /dev/null +++ b/apps/server/src/routes/templates.ts @@ -0,0 +1,171 @@ +/** + * Templates routes + * Provides API for cloning GitHub starter templates + */ + +import { Router, type Request, type Response } from "express"; +import { spawn } from "child_process"; +import path from "path"; +import fs from "fs/promises"; +import { addAllowedPath } from "../lib/security.js"; + +export function createTemplatesRoutes(): Router { + const router = Router(); + + /** + * Clone a GitHub template to a new project directory + * POST /api/templates/clone + * Body: { repoUrl: string, projectName: string, parentDir: string } + */ + router.post("/clone", async (req: Request, res: Response) => { + try { + const { repoUrl, projectName, parentDir } = req.body as { + repoUrl: string; + projectName: string; + parentDir: string; + }; + + // Validate inputs + if (!repoUrl || !projectName || !parentDir) { + res.status(400).json({ + success: false, + error: "repoUrl, projectName, and parentDir are required", + }); + return; + } + + // Validate repo URL is a valid GitHub URL + const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/; + if (!githubUrlPattern.test(repoUrl)) { + res.status(400).json({ + success: false, + error: "Invalid GitHub repository URL", + }); + return; + } + + // Sanitize project name (allow alphanumeric, dash, underscore) + const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-"); + if (sanitizedName !== projectName) { + console.log( + `[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}` + ); + } + + // Build full project path + const projectPath = path.join(parentDir, sanitizedName); + + // Check if directory already exists + try { + await fs.access(projectPath); + res.status(400).json({ + success: false, + error: `Directory "${sanitizedName}" already exists in ${parentDir}`, + }); + return; + } catch { + // Directory doesn't exist, which is what we want + } + + // Ensure parent directory exists + try { + await fs.mkdir(parentDir, { recursive: true }); + } catch (error) { + console.error("[Templates] Failed to create parent directory:", error); + res.status(500).json({ + success: false, + error: "Failed to create parent directory", + }); + return; + } + + console.log(`[Templates] Cloning ${repoUrl} to ${projectPath}`); + + // Clone the repository + const cloneResult = await new Promise<{ + success: boolean; + error?: string; + }>((resolve) => { + const gitProcess = spawn("git", ["clone", repoUrl, projectPath], { + cwd: parentDir, + }); + + let stderr = ""; + + gitProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + gitProcess.on("close", (code) => { + if (code === 0) { + resolve({ success: true }); + } else { + resolve({ + success: false, + error: stderr || `Git clone failed with code ${code}`, + }); + } + }); + + gitProcess.on("error", (error) => { + resolve({ + success: false, + error: `Failed to spawn git: ${error.message}`, + }); + }); + }); + + if (!cloneResult.success) { + res.status(500).json({ + success: false, + error: cloneResult.error || "Failed to clone repository", + }); + return; + } + + // Remove .git directory to start fresh + try { + const gitDir = path.join(projectPath, ".git"); + await fs.rm(gitDir, { recursive: true, force: true }); + console.log("[Templates] Removed .git directory"); + } catch (error) { + console.warn("[Templates] Could not remove .git directory:", error); + // Continue anyway - not critical + } + + // Initialize a fresh git repository + await new Promise((resolve) => { + const gitInit = spawn("git", ["init"], { + cwd: projectPath, + }); + + gitInit.on("close", () => { + console.log("[Templates] Initialized fresh git repository"); + resolve(); + }); + + gitInit.on("error", () => { + console.warn("[Templates] Could not initialize git"); + resolve(); + }); + }); + + // Add to allowed paths + addAllowedPath(projectPath); + + console.log(`[Templates] Successfully cloned template to ${projectPath}`); + + res.json({ + success: true, + projectPath, + projectName: sanitizedName, + }); + } catch (error) { + console.error("[Templates] Clone error:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} From 80cbabeeb08d857ba5e2a8def61d182639eb067e Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 20:51:01 -0500 Subject: [PATCH 09/47] various fixes --- .github/scripts/upload-to-r2.js | 106 ++++++++++-------- .github/workflows/release.yml | 2 +- apps/app/src/components/layout/sidebar.tsx | 102 +++++++++++++++-- apps/app/src/components/new-project-modal.tsx | 37 +++++- apps/app/src/components/views/board-view.tsx | 19 +++- .../src/components/views/interview-view.tsx | 12 +- apps/app/src/components/views/kanban-card.tsx | 66 ++++++++++- .../app/src/components/views/welcome-view.tsx | 19 +++- .../app/src/contexts/file-browser-context.tsx | 24 +++- apps/app/src/store/app-store.ts | 81 +++++++++++++ apps/server/src/routes/fs.ts | 80 +++++++++++++ apps/server/src/routes/running-agents.ts | 50 +-------- apps/server/src/services/auto-mode-service.ts | 24 ++++ 13 files changed, 496 insertions(+), 126 deletions(-) diff --git a/.github/scripts/upload-to-r2.js b/.github/scripts/upload-to-r2.js index 336069cb..67940265 100644 --- a/.github/scripts/upload-to-r2.js +++ b/.github/scripts/upload-to-r2.js @@ -1,10 +1,14 @@ -const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); -const fs = require('fs'); -const path = require('path'); +const { + S3Client, + PutObjectCommand, + GetObjectCommand, +} = require("@aws-sdk/client-s3"); +const fs = require("fs"); +const path = require("path"); const s3Client = new S3Client({ - region: 'auto', - endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + region: "auto", + endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, @@ -18,15 +22,17 @@ const GITHUB_REPO = process.env.GITHUB_REPOSITORY; async function fetchExistingReleases() { try { - const response = await s3Client.send(new GetObjectCommand({ - Bucket: BUCKET, - Key: 'releases.json', - })); + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET, + Key: "releases.json", + }) + ); const body = await response.Body.transformToString(); return JSON.parse(body); } catch (error) { - if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { - console.log('No existing releases.json found, creating new one'); + if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { + console.log("No existing releases.json found, creating new one"); return { latestVersion: null, releases: [] }; } throw error; @@ -37,12 +43,14 @@ async function uploadFile(localPath, r2Key, contentType) { const fileBuffer = fs.readFileSync(localPath); const stats = fs.statSync(localPath); - await s3Client.send(new PutObjectCommand({ - Bucket: BUCKET, - Key: r2Key, - Body: fileBuffer, - ContentType: contentType, - })); + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: r2Key, + Body: fileBuffer, + ContentType: contentType, + }) + ); console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`); return stats.size; @@ -51,44 +59,44 @@ async function uploadFile(localPath, r2Key, contentType) { function findArtifacts(dir, pattern) { if (!fs.existsSync(dir)) return []; const files = fs.readdirSync(dir); - return files.filter(f => pattern.test(f)).map(f => path.join(dir, f)); + return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f)); } async function main() { - const artifactsDir = 'artifacts'; + const artifactsDir = "artifacts"; // Find all artifacts const artifacts = { - windows: findArtifacts( - path.join(artifactsDir, 'windows-builds'), - /\.exe$/ - ), - macos: findArtifacts( - path.join(artifactsDir, 'macos-builds'), - /-x64\.dmg$/ - ), + windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/), + macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/), macosArm: findArtifacts( - path.join(artifactsDir, 'macos-builds'), + path.join(artifactsDir, "macos-builds"), /-arm64\.dmg$/ ), linux: findArtifacts( - path.join(artifactsDir, 'linux-builds'), + path.join(artifactsDir, "linux-builds"), /\.AppImage$/ ), }; - console.log('Found artifacts:'); + console.log("Found artifacts:"); for (const [platform, files] of Object.entries(artifacts)) { - console.log(` ${platform}: ${files.length > 0 ? files.map(f => path.basename(f)).join(', ') : 'none'}`); + console.log( + ` ${platform}: ${ + files.length > 0 + ? files.map((f) => path.basename(f)).join(", ") + : "none" + }` + ); } // Upload each artifact to R2 const assets = {}; const contentTypes = { - windows: 'application/x-msdownload', - macos: 'application/x-apple-diskimage', - macosArm: 'application/x-apple-diskimage', - linux: 'application/x-executable', + windows: "application/x-msdownload", + macos: "application/x-apple-diskimage", + macosArm: "application/x-apple-diskimage", + linux: "application/x-executable", }; for (const [platform, files] of Object.entries(artifacts)) { @@ -107,7 +115,7 @@ async function main() { url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`, filename, size, - arch: platform === 'macosArm' ? 'arm64' : 'x64', + arch: platform === "macosArm" ? "arm64" : "x64", }; } @@ -122,27 +130,31 @@ async function main() { }; // Remove existing entry for this version if re-running - releasesData.releases = releasesData.releases.filter(r => r.version !== VERSION); + releasesData.releases = releasesData.releases.filter( + (r) => r.version !== VERSION + ); // Prepend new release releasesData.releases.unshift(newRelease); releasesData.latestVersion = VERSION; // Upload updated releases.json - await s3Client.send(new PutObjectCommand({ - Bucket: BUCKET, - Key: 'releases.json', - Body: JSON.stringify(releasesData, null, 2), - ContentType: 'application/json', - CacheControl: 'public, max-age=60', - })); + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: "releases.json", + Body: JSON.stringify(releasesData, null, 2), + ContentType: "application/json", + CacheControl: "public, max-age=60", + }) + ); - console.log('Successfully updated releases.json'); + console.log("Successfully updated releases.json"); console.log(`Latest version: ${VERSION}`); console.log(`Total releases: ${releasesData.releases.length}`); } -main().catch(err => { - console.error('Failed to upload to R2:', err); +main().catch((err) => { + console.error("Failed to upload to R2:", err); process.exit(1); }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5135a73b..11abdcd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: - name: Upload to R2 and update releases.json env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 222e54e2..e659b282 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -42,6 +42,7 @@ import { Search, Bug, Activity, + Recycle, } from "lucide-react"; import { DropdownMenu, @@ -70,7 +71,7 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject } from "@/lib/electron"; +import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -80,6 +81,7 @@ import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; +import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; import { DndContext, DragEndEvent, @@ -212,6 +214,7 @@ export function Sidebar() { setProjectTheme, setTheme, theme: globalTheme, + moveProjectToTrash, } = useAppStore(); // Get customizable keyboard shortcuts @@ -225,6 +228,12 @@ export function Sidebar() { const [activeTrashId, setActiveTrashId] = useState(null); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + // State for delete project confirmation dialog + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); + + // State for running agents count + const [runningAgentsCount, setRunningAgentsCount] = useState(0); + // State for new project setup dialog const [showSetupDialog, setShowSetupDialog] = useState(false); const [setupProjectPath, setSetupProjectPath] = useState(""); @@ -334,6 +343,64 @@ export function Sidebar() { }; }, [setCurrentView]); + // Fetch running agents count and update every 2 seconds + useEffect(() => { + const fetchRunningAgentsCount = async () => { + try { + const api = getElectronAPI(); + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error("[Sidebar] Error fetching running agents count:", error); + } + }; + + // Initial fetch + fetchRunningAgentsCount(); + + // Set up interval to refresh every 2 seconds + const interval = setInterval(fetchRunningAgentsCount, 2000); + + return () => clearInterval(interval); + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event) => { + // When a feature starts, completes, or errors, refresh the count + if ( + event.type === "auto_mode_feature_complete" || + event.type === "auto_mode_error" || + event.type === "auto_mode_feature_started" + ) { + const fetchRunningAgentsCount = async () => { + try { + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error("[Sidebar] Error fetching running agents count:", error); + } + }; + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { if (!setupProjectPath || !projectOverview.trim()) return; @@ -534,14 +601,14 @@ export function Sidebar() { } const confirmed = window.confirm( - "Clear all trashed projects from Automaker? This does not delete folders from disk." + "Clear all projects from recycle bin? This does not delete folders from disk." ); if (!confirmed) return; setIsEmptyingTrash(true); try { emptyTrash(); - toast.success("Trash cleared"); + toast.success("Recycle bin cleared"); setShowTrashDialog(false); } finally { setIsEmptyingTrash(false); @@ -830,10 +897,10 @@ export function Sidebar() { )} @@ -1421,6 +1499,14 @@ export function Sidebar() { )} + + {/* Delete Project Confirmation Dialog */} + ); } diff --git a/apps/app/src/components/new-project-modal.tsx b/apps/app/src/components/new-project-modal.tsx index 562363c4..fd1429de 100644 --- a/apps/app/src/components/new-project-modal.tsx +++ b/apps/app/src/components/new-project-modal.tsx @@ -17,6 +17,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { FolderPlus, + FolderOpen, Rocket, ExternalLink, Check, @@ -28,6 +29,7 @@ import { starterTemplates, type StarterTemplate } from "@/lib/templates"; import { getElectronAPI } from "@/lib/electron"; import { getHttpApiClient } from "@/lib/http-api-client"; import { cn } from "@/lib/utils"; +import { useFileBrowser } from "@/contexts/file-browser-context"; interface ValidationErrors { projectName?: boolean; @@ -69,6 +71,7 @@ export function NewProjectModal({ const [useCustomUrl, setUseCustomUrl] = useState(false); const [customUrl, setCustomUrl] = useState(""); const [errors, setErrors] = useState({}); + const { openFileBrowser } = useFileBrowser(); // Fetch workspace directory when modal opens useEffect(() => { @@ -181,6 +184,20 @@ export function NewProjectModal({ } }; + const handleBrowseDirectory = async () => { + const selectedPath = await openFileBrowser({ + title: "Select Base Project Directory", + description: "Choose the parent directory where your project will be created", + }); + if (selectedPath) { + setWorkspaceDir(selectedPath); + // Clear any workspace error when a valid directory is selected + if (errors.workspaceDir) { + setErrors((prev) => ({ ...prev, workspaceDir: false })); + } + } + }; + const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; return ( @@ -226,16 +243,28 @@ export function NewProjectModal({ "flex items-center gap-2 text-sm", errors.workspaceDir ? "text-red-500" : "text-muted-foreground" )}> - - + + {isLoadingWorkspace ? ( "Loading workspace..." ) : workspaceDir ? ( - <>Will be created at: {projectPath || "..."} + <>Will be created at: {projectPath || "..."} ) : ( - No workspace configured - please configure WORKSPACE_DIR + No workspace configured )} + diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 8aa70362..ce2d0e87 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -874,7 +874,8 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - persistFeatureUpdate(featureId, { status: "verified" }); + // Clear justFinished flag when manually verifying via drag + persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -884,7 +885,8 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - persistFeatureUpdate(featureId, { status: "backlog" }); + // Clear justFinished flag when moving back to backlog + persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1205,7 +1207,8 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - persistFeatureUpdate(feature.id, { status: "verified" }); + // Clear justFinished flag when manually verifying + persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1271,9 +1274,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up + // Clear justFinished flag since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), + justFinished: false, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1532,6 +1537,14 @@ export function BoardView() { } }); + // Sort waiting_approval column: justFinished features go to the top + map.waiting_approval.sort((a, b) => { + // Features with justFinished=true should appear first + if (a.justFinished && !b.justFinished) return -1; + if (!a.justFinished && b.justFinished) return 1; + return 0; // Keep original order for features with same justFinished status + }); + return map; }, [features, runningAutoTasks, searchQuery]); diff --git a/apps/app/src/components/views/interview-view.tsx b/apps/app/src/components/views/interview-view.tsx index 4e4d1b26..78110faa 100644 --- a/apps/app/src/components/views/interview-view.tsx +++ b/apps/app/src/components/views/interview-view.tsx @@ -18,6 +18,7 @@ import { import { cn } from "@/lib/utils"; import { getElectronAPI } from "@/lib/electron"; import { Markdown } from "@/components/ui/markdown"; +import { useFileBrowser } from "@/contexts/file-browser-context"; interface InterviewMessage { id: string; @@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [ export function InterviewView() { const { setCurrentView, addProject, setCurrentProject, setAppSpec } = useAppStore(); + const { openFileBrowser } = useFileBrowser(); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -286,11 +288,13 @@ export function InterviewView() { }; const handleSelectDirectory = async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); + const selectedPath = await openFileBrowser({ + title: "Select Base Directory", + description: "Choose the parent directory where your new project will be created", + }); - if (!result.canceled && result.filePaths[0]) { - setProjectPath(result.filePaths[0]); + if (selectedPath) { + setProjectPath(selectedPath); } }; diff --git a/apps/app/src/components/views/kanban-card.tsx b/apps/app/src/components/views/kanban-card.tsx index a0e06ad1..9e8796b7 100644 --- a/apps/app/src/components/views/kanban-card.tsx +++ b/apps/app/src/components/views/kanban-card.tsx @@ -28,7 +28,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Feature, useAppStore } from "@/store/app-store"; +import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store"; import { GripVertical, Edit, @@ -56,6 +56,7 @@ import { GitMerge, ChevronDown, ChevronUp, + Brain, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; @@ -73,6 +74,21 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +/** + * Formats thinking level for compact display + */ +function formatThinkingLevel(level: ThinkingLevel | undefined): string { + if (!level || level === "none") return ""; + const labels: Record = { + none: "", + low: "Low", + medium: "Med", + high: "High", + ultrathink: "Ultra", + }; + return labels[level]; +} + interface KanbanCardProps { feature: Feature; onEdit: () => void; @@ -276,6 +292,21 @@ export const KanbanCard = memo(function KanbanCard({ Errored )} + {/* Just Finished indicator badge - shows when agent just completed work */} + {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( +
+ + Done +
+ )} {/* Branch badge - show when feature has a worktree */} {hasWorktree && !isCurrentAutoTask && ( @@ -285,8 +316,8 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "bg-purple-500/20 border border-purple-500/50 text-purple-400", - // Position below error badge if present, otherwise use normal position - feature.error || feature.skipTests + // Position below other badges if present, otherwise use normal position + feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval") ? "top-8 left-2" : "top-2 left-2" )} @@ -306,14 +337,17 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout // Add extra top padding when badges are present to prevent text overlap - (feature.skipTests || feature.error) && "pt-10", + (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10", // Add even more top padding when both badges and branch are shown - hasWorktree && (feature.skipTests || feature.error) && "pt-14" + hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" )} > {isCurrentAutoTask && (
+ + {formatModelName(feature.model ?? DEFAULT_MODEL)} + {feature.startedAt && ( )} + {/* Model/Preset Info for Backlog Cards - Show in Detailed mode */} + {showAgentInfo && feature.status === "backlog" && ( +
+
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+ {feature.thinkingLevel && feature.thinkingLevel !== "none" && ( +
+ + + {formatThinkingLevel(feature.thinkingLevel)} + +
+ )} +
+
+ )} + {/* Agent Info Panel - shows for in_progress, waiting_approval, verified */} {/* Detailed mode: Show all agent info */} {showAgentInfo && feature.status !== "backlog" && agentInfo && ( diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index bd161a9e..36744cb1 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -181,7 +181,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -193,7 +194,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -231,7 +233,10 @@ export function WelcomeView() { /** * Create a blank project with just .automaker directory structure */ - const handleCreateBlankProject = async (projectName: string, parentDir: string) => { + const handleCreateBlankProject = async ( + projectName: string, + parentDir: string + ) => { setIsCreating(true); try { const api = getElectronAPI(); @@ -359,11 +364,15 @@ export function WelcomeView() { - ${template.techStack.map((tech) => `${tech}`).join("\n ")} + ${template.techStack + .map((tech) => `${tech}`) + .join("\n ")} - ${template.features.map((feature) => `${feature}`).join("\n ")} + ${template.features + .map((feature) => `${feature}`) + .join("\n ")} diff --git a/apps/app/src/contexts/file-browser-context.tsx b/apps/app/src/contexts/file-browser-context.tsx index f54fb27f..b4c0b4ee 100644 --- a/apps/app/src/contexts/file-browser-context.tsx +++ b/apps/app/src/contexts/file-browser-context.tsx @@ -3,8 +3,13 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog"; +interface FileBrowserOptions { + title?: string; + description?: string; +} + interface FileBrowserContextValue { - openFileBrowser: () => Promise; + openFileBrowser: (options?: FileBrowserOptions) => Promise; } const FileBrowserContext = createContext(null); @@ -12,9 +17,11 @@ const FileBrowserContext = createContext(null); export function FileBrowserProvider({ children }: { children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null); + const [dialogOptions, setDialogOptions] = useState({}); - const openFileBrowser = useCallback((): Promise => { + const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise => { return new Promise((resolve) => { + setDialogOptions(options || {}); setIsOpen(true); setResolver(() => resolve); }); @@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { setResolver(null); } setIsOpen(false); + setDialogOptions({}); }, [resolver]); const handleOpenChange = useCallback((open: boolean) => { @@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { setResolver(null); } setIsOpen(open); + if (!open) { + setDialogOptions({}); + } }, [resolver]); return ( @@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) { open={isOpen} onOpenChange={handleOpenChange} onSelect={handleSelect} + title={dialogOptions.title} + description={dialogOptions.description} /> ); @@ -57,12 +70,15 @@ export function useFileBrowser() { } // Global reference for non-React code (like HttpApiClient) -let globalFileBrowserFn: (() => Promise) | null = null; +let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise) | null = null; -export function setGlobalFileBrowser(fn: () => Promise) { +export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise) { globalFileBrowserFn = fn; } export function getGlobalFileBrowser() { return globalFileBrowserFn; } + +// Export the options type for consumers +export type { FileBrowserOptions }; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 972b9b55..640ab47f 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -246,6 +246,7 @@ export interface Feature { // Worktree info - set when a feature is being worked on in an isolated git worktree worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch + justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval } // File tree node for project analysis @@ -333,6 +334,13 @@ export interface AppState { // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; + + // Board Background Settings (per-project, keyed by project path) + boardBackgroundByProject: Record; } export interface AutoModeActivity { @@ -457,6 +465,13 @@ export interface AppActions { setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; getLastSelectedSession: (projectPath: string) => string | null; + // Board Background actions + setBoardBackground: (projectPath: string, imagePath: string | null) => void; + setCardOpacity: (projectPath: string, opacity: number) => void; + setColumnOpacity: (projectPath: string, opacity: number) => void; + getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + clearBoardBackground: (projectPath: string) => void; + // Reset reset: () => void; } @@ -558,6 +573,7 @@ const initialState: AppState = { aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, + boardBackgroundByProject: {}, }; export const useAppStore = create()( @@ -1150,6 +1166,69 @@ export const useAppStore = create()( getLastSelectedSession: (projectPath) => { return get().lastSelectedSessionByProject[projectPath] || null; }, + + // Board Background actions + setBoardBackground: (projectPath, imagePath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath, + }, + }, + }); + }, + + setCardOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardOpacity: opacity, + }, + }, + }); + }, + + setColumnOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnOpacity: opacity, + }, + }, + }); + }, + + getBoardBackground: (projectPath) => { + const settings = get().boardBackgroundByProject[projectPath]; + return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + }, + }, + }); + }, + // Reset reset: () => set(initialState), }), @@ -1197,6 +1276,8 @@ export const useAppStore = create()( aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + // Board background settings + boardBackgroundByProject: state.boardBackgroundByProject, }), } ) diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 5250e8f0..ef227918 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -499,6 +499,86 @@ export function createFsRoutes(_events: EventEmitter): Router { } }); + // Save board background image to .automaker/board directory + router.post("/save-board-background", async (req: Request, res: Response) => { + try { + const { data, filename, mimeType, projectPath } = req.body as { + data: string; + filename: string; + mimeType: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: "data, filename, and projectPath are required", + }); + return; + } + + // Create .automaker/board directory if it doesn't exist + const boardDir = path.join(projectPath, ".automaker", "board"); + await fs.mkdir(boardDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Use a fixed filename for the board background (overwrite previous) + const ext = path.extname(filename) || ".png"; + const uniqueFilename = `background${ext}`; + const filePath = path.join(boardDir, uniqueFilename); + + // Write file + await fs.writeFile(filePath, buffer); + + // Add project path to allowed paths if not already + addAllowedPath(projectPath); + + // Return the relative path for storage + const relativePath = `.automaker/board/${uniqueFilename}`; + res.json({ success: true, path: relativePath }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Delete board background image + router.post("/delete-board-background", async (req: Request, res: Response) => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", + }); + return; + } + + const boardDir = path.join(projectPath, ".automaker", "board"); + + try { + // Try to remove all files in the board directory + const files = await fs.readdir(boardDir); + for (const file of files) { + if (file.startsWith("background")) { + await fs.unlink(path.join(boardDir, file)); + } + } + } catch { + // Directory may not exist, that's fine + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + // Browse directories for file picker // SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows router.post("/browse", async (req: Request, res: Response) => { diff --git a/apps/server/src/routes/running-agents.ts b/apps/server/src/routes/running-agents.ts index 57285636..116a5b00 100644 --- a/apps/server/src/routes/running-agents.ts +++ b/apps/server/src/routes/running-agents.ts @@ -3,32 +3,22 @@ */ import { Router, type Request, type Response } from "express"; -import path from "path"; +import type { AutoModeService } from "../services/auto-mode-service.js"; -interface RunningAgent { - featureId: string; - projectPath: string; - projectName: string; - isAutoMode: boolean; -} - -// In-memory tracking of running agents (shared with auto-mode service via reference) -const runningAgentsMap = new Map(); -let autoLoopRunning = false; - -export function createRunningAgentsRoutes(): Router { +export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { const router = Router(); // Get all running agents router.get("/", async (_req: Request, res: Response) => { try { - const runningAgents = Array.from(runningAgentsMap.values()); + const runningAgents = autoModeService.getRunningAgents(); + const status = autoModeService.getStatus(); res.json({ success: true, runningAgents, totalCount: runningAgents.length, - autoLoopRunning, + autoLoopRunning: status.autoLoopRunning, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): Router { return router; } - -// Export functions to update running agents from other services -export function registerRunningAgent( - featureId: string, - projectPath: string, - isAutoMode: boolean -): void { - runningAgentsMap.set(featureId, { - featureId, - projectPath, - projectName: path.basename(projectPath), - isAutoMode, - }); -} - -export function unregisterRunningAgent(featureId: string): void { - runningAgentsMap.delete(featureId); -} - -export function setAutoLoopRunning(running: boolean): void { - autoLoopRunning = running; -} - -export function getRunningAgentsCount(): number { - return runningAgentsMap.size; -} - -export function isAgentRunning(featureId: string): boolean { - return runningAgentsMap.has(featureId); -} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 5d3385b8..ffb8b171 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -709,6 +709,23 @@ Format your response as a structured markdown document.`; }; } + /** + * Get detailed info about all running agents + */ + getRunningAgents(): Array<{ + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; + }> { + return Array.from(this.runningFeatures.values()).map((rf) => ({ + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + })); + } + // Private helpers private async setupWorktree( @@ -785,6 +802,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); + // Set justFinished flag when moving to waiting_approval (agent just completed) + if (status === "waiting_approval") { + feature.justFinished = true; + } else { + // Clear the flag when moving to other statuses + feature.justFinished = false; + } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { // Feature file may not exist From ebd928e3b68d4b932ccf95b29013b0d005f61915 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:05:16 -0500 Subject: [PATCH 10/47] feat: add red theme and board background modal - Introduced a new red theme with custom color variables for a bold aesthetic. - Updated the theme management to include the new red theme option. - Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls. - Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility. - Updated API client to handle saving and deleting board backgrounds. - Refactored theme application logic to accommodate the new preview theme functionality. --- apps/app/src/app/globals.css | 70 +++ apps/app/src/app/page.tsx | 23 +- .../dialogs/board-background-modal.tsx | 533 ++++++++++++++++++ apps/app/src/components/layout/sidebar.tsx | 184 ++++-- apps/app/src/components/views/board-view.tsx | 512 ++++++++++------- apps/app/src/components/views/kanban-card.tsx | 149 ++++- .../src/components/views/kanban-column.tsx | 38 +- .../views/settings-view/shared/types.ts | 3 +- apps/app/src/config/theme-options.ts | 7 + apps/app/src/lib/http-api-client.ts | 20 + apps/app/src/store/app-store.ts | 384 ++++++++++--- apps/server/src/index.ts | 6 +- apps/server/src/routes/auto-mode.ts | 6 +- apps/server/src/services/auto-mode-service.ts | 152 ++++- 14 files changed, 1700 insertions(+), 387 deletions(-) create mode 100644 apps/app/src/components/dialogs/board-background-modal.tsx diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 2f7dc659..7036229e 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -12,6 +12,7 @@ @custom-variant catppuccin (&:is(.catppuccin *)); @custom-variant onedark (&:is(.onedark *)); @custom-variant synthwave (&:is(.synthwave *)); +@custom-variant red (&:is(.red *)); @theme inline { --color-background: var(--background); @@ -1072,6 +1073,75 @@ --running-indicator-text: oklch(0.75 0.26 350); } +/* Red Theme - Bold crimson/red aesthetic */ +.red { + --background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */ + --background-50: oklch(0.12 0.03 15 / 0.5); + --background-80: oklch(0.12 0.03 15 / 0.8); + + --foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */ + --foreground-secondary: oklch(0.7 0.02 15); + --foreground-muted: oklch(0.5 0.03 15); + + --card: oklch(0.18 0.04 15); /* Slightly lighter dark red */ + --card-foreground: oklch(0.95 0.01 15); + --popover: oklch(0.15 0.035 15); + --popover-foreground: oklch(0.95 0.01 15); + + --primary: oklch(0.55 0.25 25); /* Vibrant crimson red */ + --primary-foreground: oklch(0.98 0 0); + + --brand-400: oklch(0.6 0.23 25); + --brand-500: oklch(0.55 0.25 25); /* Crimson */ + --brand-600: oklch(0.5 0.27 25); + + --secondary: oklch(0.22 0.05 15); + --secondary-foreground: oklch(0.95 0.01 15); + + --muted: oklch(0.22 0.05 15); + --muted-foreground: oklch(0.5 0.03 15); + + --accent: oklch(0.28 0.06 15); + --accent-foreground: oklch(0.95 0.01 15); + + --destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */ + + --border: oklch(0.35 0.08 15); + --border-glass: oklch(0.55 0.25 25 / 0.3); + + --input: oklch(0.18 0.04 15); + --ring: oklch(0.55 0.25 25); + + --chart-1: oklch(0.55 0.25 25); /* Crimson */ + --chart-2: oklch(0.7 0.2 50); /* Orange */ + --chart-3: oklch(0.8 0.18 80); /* Gold */ + --chart-4: oklch(0.6 0.22 0); /* Pure red */ + --chart-5: oklch(0.65 0.2 350); /* Pink-red */ + + --sidebar: oklch(0.1 0.025 15); + --sidebar-foreground: oklch(0.95 0.01 15); + --sidebar-primary: oklch(0.55 0.25 25); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.22 0.05 15); + --sidebar-accent-foreground: oklch(0.95 0.01 15); + --sidebar-border: oklch(0.35 0.08 15); + --sidebar-ring: oklch(0.55 0.25 25); + + /* Action button colors - Red theme */ + --action-view: oklch(0.55 0.25 25); /* Crimson */ + --action-view-hover: oklch(0.5 0.27 25); + --action-followup: oklch(0.7 0.2 50); /* Orange */ + --action-followup-hover: oklch(0.65 0.22 50); + --action-commit: oklch(0.6 0.2 140); /* Green for positive actions */ + --action-commit-hover: oklch(0.55 0.22 140); + --action-verify: oklch(0.6 0.2 140); /* Green */ + --action-verify-hover: oklch(0.55 0.22 140); + + /* Running indicator - Crimson */ + --running-indicator: oklch(0.55 0.25 25); + --running-indicator-text: oklch(0.6 0.23 25); +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 14e200be..0397f513 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -15,7 +15,11 @@ import { RunningAgentsView } from "@/components/views/running-agents-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; -import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context"; +import { + FileBrowserProvider, + useFileBrowser, + setGlobalFileBrowser, +} from "@/contexts/file-browser-context"; function HomeContent() { const { @@ -24,6 +28,8 @@ function HomeContent() { setIpcConnected, theme, currentProject, + previewTheme, + getEffectiveTheme, } = useAppStore(); const { isFirstRun, setupComplete } = useSetupStore(); const [isMounted, setIsMounted] = useState(false); @@ -72,9 +78,9 @@ function HomeContent() { }; }, [handleStreamerPanelShortcut]); - // Compute the effective theme: project theme takes priority over global theme - // This is reactive because it depends on currentProject and theme from the store - const effectiveTheme = currentProject?.theme || theme; + // Compute the effective theme: previewTheme takes priority, then project theme, then global theme + // This is reactive because it depends on previewTheme, currentProject, and theme from the store + const effectiveTheme = getEffectiveTheme(); // Prevent hydration issues useEffect(() => { @@ -122,7 +128,7 @@ function HomeContent() { testConnection(); }, [setIpcConnected]); - // Apply theme class to document (uses effective theme - project-specific or global) + // Apply theme class to document (uses effective theme - preview, project-specific, or global) useEffect(() => { const root = document.documentElement; root.classList.remove( @@ -137,7 +143,8 @@ function HomeContent() { "gruvbox", "catppuccin", "onedark", - "synthwave" + "synthwave", + "red" ); if (effectiveTheme === "dark") { @@ -162,6 +169,8 @@ function HomeContent() { root.classList.add("onedark"); } else if (effectiveTheme === "synthwave") { root.classList.add("synthwave"); + } else if (effectiveTheme === "red") { + root.classList.add("red"); } else if (effectiveTheme === "light") { root.classList.add("light"); } else if (effectiveTheme === "system") { @@ -173,7 +182,7 @@ function HomeContent() { root.classList.add("light"); } } - }, [effectiveTheme]); + }, [effectiveTheme, previewTheme, currentProject, theme]); const renderView = () => { switch (currentView) { diff --git a/apps/app/src/components/dialogs/board-background-modal.tsx b/apps/app/src/components/dialogs/board-background-modal.tsx new file mode 100644 index 00000000..f22ac280 --- /dev/null +++ b/apps/app/src/components/dialogs/board-background-modal.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { useAppStore } from "@/store/app-store"; +import { getHttpApiClient } from "@/lib/http-api-client"; +import { toast } from "sonner"; + +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]; +const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +interface BoardBackgroundModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BoardBackgroundModal({ + open, + onOpenChange, +}: BoardBackgroundModalProps) { + const { + currentProject, + boardBackgroundByProject, + setBoardBackground, + setCardOpacity, + setColumnOpacity, + setColumnBorderEnabled, + setCardGlassmorphism, + setCardBorderEnabled, + setCardBorderOpacity, + setHideScrollbar, + clearBoardBackground, + } = useAppStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const fileInputRef = useRef(null); + const [previewImage, setPreviewImage] = useState(null); + + // Get current background settings (live from store) + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + const cardOpacity = backgroundSettings.cardOpacity; + const columnOpacity = backgroundSettings.columnOpacity; + const columnBorderEnabled = backgroundSettings.columnBorderEnabled; + const cardGlassmorphism = backgroundSettings.cardGlassmorphism; + const cardBorderEnabled = backgroundSettings.cardBorderEnabled; + const cardBorderOpacity = backgroundSettings.cardBorderOpacity; + const hideScrollbar = backgroundSettings.hideScrollbar; + + // Update preview image when background settings change + useEffect(() => { + if (currentProject && backgroundSettings.imagePath) { + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent(currentProject.path)}`; + setPreviewImage(imagePath); + } else { + setPreviewImage(null); + } + }, [currentProject, backgroundSettings.imagePath]); + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("Failed to read file as base64")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + }; + + const processFile = useCallback( + async (file: File) => { + if (!currentProject) { + toast.error("No project selected"); + return; + } + + // Validate file type + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + toast.error( + "Unsupported file type. Please use JPG, PNG, GIF, or WebP." + ); + return; + } + + // Validate file size + if (file.size > DEFAULT_MAX_FILE_SIZE) { + const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024); + toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + return; + } + + setIsProcessing(true); + try { + const base64 = await fileToBase64(file); + + // Set preview immediately + setPreviewImage(base64); + + // Save to server + const httpClient = getHttpApiClient(); + const result = await httpClient.saveBoardBackground( + base64, + file.name, + file.type, + currentProject.path + ); + + if (result.success && result.path) { + // Update store with the relative path (live update) + setBoardBackground(currentProject.path, result.path); + toast.success("Background image saved"); + } else { + toast.error(result.error || "Failed to save background image"); + setPreviewImage(null); + } + } catch (error) { + console.error("Failed to process image:", error); + toast.error("Failed to process image"); + setPreviewImage(null); + } finally { + setIsProcessing(false); + } + }, + [currentProject, setBoardBackground] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFile(files[0]); + } + }, + [processFile] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFile(files[0]); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, + [processFile] + ); + + const handleBrowseClick = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleClear = useCallback(async () => { + if (!currentProject) return; + + try { + setIsProcessing(true); + const httpClient = getHttpApiClient(); + const result = await httpClient.deleteBoardBackground( + currentProject.path + ); + + if (result.success) { + clearBoardBackground(currentProject.path); + setPreviewImage(null); + toast.success("Background image cleared"); + } else { + toast.error(result.error || "Failed to clear background image"); + } + } catch (error) { + console.error("Failed to clear background:", error); + toast.error("Failed to clear background"); + } finally { + setIsProcessing(false); + } + }, [currentProject, clearBoardBackground]); + + // Live update opacity when sliders change + const handleCardOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setCardOpacity(currentProject.path, value[0]); + }, + [currentProject, setCardOpacity] + ); + + const handleColumnOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setColumnOpacity(currentProject.path, value[0]); + }, + [currentProject, setColumnOpacity] + ); + + const handleColumnBorderToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setColumnBorderEnabled(currentProject.path, checked); + }, + [currentProject, setColumnBorderEnabled] + ); + + const handleCardGlassmorphismToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setCardGlassmorphism(currentProject.path, checked); + }, + [currentProject, setCardGlassmorphism] + ); + + const handleCardBorderToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setCardBorderEnabled(currentProject.path, checked); + }, + [currentProject, setCardBorderEnabled] + ); + + const handleCardBorderOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setCardBorderOpacity(currentProject.path, value[0]); + }, + [currentProject, setCardBorderOpacity] + ); + + const handleHideScrollbarToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setHideScrollbar(currentProject.path, checked); + }, + [currentProject, setHideScrollbar] + ); + + if (!currentProject) { + return null; + } + + return ( + + + + + + Board Background Settings + + + Set a custom background image for your kanban board and adjust + card/column opacity + + + +
+ {/* Image Upload Section */} +
+ + + {/* Hidden file input */} + + + {/* Drop zone */} +
+ {previewImage ? ( +
+
+ Background preview + {isProcessing && ( +
+ +
+ )} +
+
+ + +
+
+ ) : ( +
+
+ {isProcessing ? ( + + ) : ( + + )} +
+

+ {isDragOver && !isProcessing + ? "Drop image here" + : "Click to upload or drag and drop"} +

+

+ JPG, PNG, GIF, or WebP (max{" "} + {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB) +

+
+ )} +
+
+ + {/* Opacity Controls */} +
+
+
+ + + {cardOpacity}% + +
+ +
+ +
+
+ + + {columnOpacity}% + +
+ +
+ + {/* Column Border Toggle */} +
+ + +
+ + {/* Card Glassmorphism Toggle */} +
+ + +
+ + {/* Card Border Toggle */} +
+ + +
+ + {/* Card Border Opacity - only show when border is enabled */} + {cardBorderEnabled && ( +
+
+ + + {cardBorderOpacity}% + +
+ +
+ )} + + {/* Hide Scrollbar Toggle */} +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index e659b282..82e46044 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -26,18 +26,6 @@ import { UserCircle, MoreVertical, Palette, - Moon, - Sun, - Terminal, - Ghost, - Snowflake, - Flame, - Sparkles as TokyoNightIcon, - Eclipse, - Trees, - Cat, - Atom, - Radio, Monitor, Search, Bug, @@ -71,7 +59,12 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; +import { + getElectronAPI, + Project, + TrashedProject, + RunningAgent, +} from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -79,6 +72,7 @@ import { } from "@/lib/project-init"; import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; +import { themeOptions } from "@/config/theme-options"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; @@ -175,21 +169,14 @@ function SortableProjectItem({ ); } -// Theme options for project theme selector +// Theme options for project theme selector - derived from the shared config const PROJECT_THEME_OPTIONS = [ { value: "", label: "Use Global", icon: Monitor }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "light", label: "Light", icon: Sun }, - { value: "retro", label: "Retro", icon: Terminal }, - { value: "dracula", label: "Dracula", icon: Ghost }, - { value: "nord", label: "Nord", icon: Snowflake }, - { value: "monokai", label: "Monokai", icon: Flame }, - { value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon }, - { value: "solarized", label: "Solarized", icon: Eclipse }, - { value: "gruvbox", label: "Gruvbox", icon: Trees }, - { value: "catppuccin", label: "Catppuccin", icon: Cat }, - { value: "onedark", label: "One Dark", icon: Atom }, - { value: "synthwave", label: "Synthwave", icon: Radio }, + ...themeOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + })), ] as const; export function Sidebar() { @@ -213,6 +200,7 @@ export function Sidebar() { clearProjectHistory, setProjectTheme, setTheme, + setPreviewTheme, theme: globalTheme, moveProjectToTrash, } = useAppStore(); @@ -389,7 +377,10 @@ export function Sidebar() { } } } catch (error) { - console.error("[Sidebar] Error fetching running agents count:", error); + console.error( + "[Sidebar] Error fetching running agents count:", + error + ); } }; fetchRunningAgentsCount(); @@ -501,7 +492,8 @@ export function Sidebar() { // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) // Then fall back to current effective theme, then global theme const trashedProject = trashedProjects.find((p) => p.path === path); - const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme; + const effectiveTheme = + trashedProject?.theme || currentProject?.theme || globalTheme; project = { id: `project-${Date.now()}`, name, @@ -546,7 +538,14 @@ export function Sidebar() { }); } } - }, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]); + }, [ + projects, + trashedProjects, + addProject, + setCurrentProject, + currentProject, + globalTheme, + ]); const handleRestoreProject = useCallback( (projectId: string) => { @@ -828,7 +827,9 @@ export function Sidebar() {
{ const api = getElectronAPI(); - api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues"); + api.openExternalLink( + "https://github.com/AutoMaker-Org/automaker/issues" + ); }} className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all" title="Report Bug / Feature Request" @@ -1001,7 +1004,14 @@ export function Sidebar() { {/* Project Options Menu - theme and history */} {currentProject && ( - + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + >
); })} @@ -1241,14 +1292,25 @@ export function Sidebar() { {isActiveRoute("running-agents") && (
)} - + + {/* Running agents count badge - shown in collapsed state */} + {!sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + )} - /> +
Running Agents + {/* Running agents count badge - shown in expanded state */} + {sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + + )} {!sidebarOpen && ( Running Agents @@ -1328,7 +1402,9 @@ export function Sidebar() { {trashedProjects.length === 0 ? ( -

Recycle bin is empty.

+

+ Recycle bin is empty. +

) : (
{trashedProjects.map((project) => ( diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index ce2d0e87..d671c603 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -58,6 +58,7 @@ import { KanbanColumn } from "./kanban-column"; import { KanbanCard } from "./kanban-card"; import { AgentOutputModal } from "./agent-output-modal"; import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; +import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { Plus, RefreshCw, @@ -86,6 +87,7 @@ import { Square, Maximize2, Shuffle, + ImageIcon, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; @@ -213,6 +215,7 @@ export function BoardView() { aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, + boardBackgroundByProject, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [activeFeature, setActiveFeature] = useState(null); @@ -237,6 +240,8 @@ export function BoardView() { ); const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = useState(false); + const [showBoardBackgroundModal, setShowBoardBackgroundModal] = + useState(false); const [persistedCategories, setPersistedCategories] = useState([]); const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); const [followUpFeature, setFollowUpFeature] = useState(null); @@ -407,7 +412,8 @@ export function BoardView() { const currentPath = currentProject.path; const previousPath = prevProjectPathRef.current; - const isProjectSwitch = previousPath !== null && currentPath !== previousPath; + const isProjectSwitch = + previousPath !== null && currentPath !== previousPath; // Get cached features from store (without adding to dependencies) const cachedFeatures = useAppStore.getState().features; @@ -563,7 +569,8 @@ export function BoardView() { const unsubscribe = api.autoMode.onEvent((event) => { // Use event's projectPath or projectId if available, otherwise use current project // Board view only reacts to events for the currently selected project - const eventProjectId = ('projectId' in event && event.projectId) || projectId; + const eventProjectId = + ("projectId" in event && event.projectId) || projectId; if (event.type === "auto_mode_feature_complete") { // Reload features when a feature is completed @@ -592,15 +599,16 @@ export function BoardView() { loadFeatures(); // Check for authentication errors and show a more helpful message - const isAuthError = event.errorType === "authentication" || - (event.error && ( - event.error.includes("Authentication failed") || - event.error.includes("Invalid API key") - )); + const isAuthError = + event.errorType === "authentication" || + (event.error && + (event.error.includes("Authentication failed") || + event.error.includes("Invalid API key"))); if (isAuthError) { toast.error("Authentication Failed", { - description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", + description: + "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", duration: 10000, }); } else { @@ -874,8 +882,11 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - // Clear justFinished flag when manually verifying via drag - persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); + // Clear justFinishedAt timestamp when manually verifying via drag + persistFeatureUpdate(featureId, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -885,8 +896,11 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - // Clear justFinished flag when moving back to backlog - persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); + // Clear justFinishedAt timestamp when moving back to backlog + persistFeatureUpdate(featureId, { + status: "backlog", + justFinishedAt: undefined, + }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1207,8 +1221,11 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - // Clear justFinished flag when manually verifying - persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); + // Clear justFinishedAt timestamp when manually verifying + persistFeatureUpdate(feature.id, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1274,11 +1291,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up - // Clear justFinished flag since user is now interacting with it + // Clear justFinishedAt timestamp since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), - justFinished: false, + justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1537,11 +1554,22 @@ export function BoardView() { } }); - // Sort waiting_approval column: justFinished features go to the top + // Sort waiting_approval column: justFinished features (within 2 minutes) go to the top map.waiting_approval.sort((a, b) => { - // Features with justFinished=true should appear first - if (a.justFinished && !b.justFinished) return -1; - if (!a.justFinished && b.justFinished) return 1; + // Helper to check if feature is "just finished" (within 2 minutes) + const isJustFinished = (feature: Feature) => { + if (!feature.justFinishedAt) return false; + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const now = Date.now(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return now - finishedTime < twoMinutes; + }; + + const aJustFinished = isJustFinished(a); + const bJustFinished = isJustFinished(b); + // Features with justFinishedAt within 2 minutes should appear first + if (aJustFinished && !bJustFinished) return -1; + if (!aJustFinished && bJustFinished) return 1; return 0; // Keep original order for features with same justFinished status }); @@ -1646,7 +1674,7 @@ export function BoardView() { return; } - const featuresToStart = backlogFeatures.slice(0, availableSlots); + const featuresToStart = backlogFeatures.slice(0, 1); for (const feature of featuresToStart) { // Update the feature status with startedAt timestamp @@ -1855,202 +1883,296 @@ export function BoardView() { )}
- {/* Kanban Card Detail Level Toggle */} + {/* Board Background & Detail Level Controls */} {isMounted && ( -
+
+ {/* Board Background Button */} - + + -

Minimal - Title & category only

-
-
- - - - - -

Standard - Steps & progress

-
-
- - - - - -

Detailed - Model, tools & tasks

+

Board Background Settings

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

Minimal - Title & category only

+
+
+ + + + + +

Standard - Steps & progress

+
+
+ + + + + +

Detailed - Model, tools & tasks

+
+
+
)}
{/* Kanban Columns */} -
- -
- {COLUMNS.map((column) => { - const columnFeatures = getColumnFeatures(column.id); - return ( - 0 ? ( - - ) : column.id === "backlog" ? ( -
- - {columnFeatures.length > 0 && ( - { + // Get background settings for current project + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + // Build background image style if image exists + const backgroundImageStyle = backgroundSettings.imagePath + ? { + backgroundImage: `url(${ + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008" + }/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent( + currentProject?.path || "" + )})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + : {}; + + return ( +
+ +
+ {COLUMNS.map((column) => { + const columnFeatures = getColumnFeatures(column.id); + return ( + 0 ? ( +
- ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} - > - {columnFeatures.map((feature, index) => { - // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) - let shortcutKey: string | undefined; - if (column.id === "in_progress" && index < 10) { - shortcutKey = index === 9 ? "0" : String(index + 1); + + Delete All + + ) : column.id === "backlog" ? ( +
+ + {columnFeatures.length > 0 && ( + + + Pull Top + + )} +
+ ) : undefined } - return ( - setEditingFeature(feature)} - onDelete={() => handleDeleteFeature(feature.id)} - onViewOutput={() => handleViewOutput(feature)} - onVerify={() => handleVerifyFeature(feature)} - onResume={() => handleResumeFeature(feature)} - onForceStop={() => handleForceStopFeature(feature)} - onManualVerify={() => handleManualVerify(feature)} - onMoveBackToInProgress={() => - handleMoveBackToInProgress(feature) + > + f.id)} + strategy={verticalListSortingStrategy} + > + {columnFeatures.map((feature, index) => { + // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) + let shortcutKey: string | undefined; + if (column.id === "in_progress" && index < 10) { + shortcutKey = + index === 9 ? "0" : String(index + 1); } - onFollowUp={() => handleOpenFollowUp(feature)} - onCommit={() => handleCommitFeature(feature)} - onRevert={() => handleRevertFeature(feature)} - onMerge={() => handleMergeFeature(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes( - feature.id - )} - shortcutKey={shortcutKey} - /> - ); - })} - - - ); - })} -
+ return ( + setEditingFeature(feature)} + onDelete={() => handleDeleteFeature(feature.id)} + onViewOutput={() => handleViewOutput(feature)} + onVerify={() => handleVerifyFeature(feature)} + onResume={() => handleResumeFeature(feature)} + onForceStop={() => + handleForceStopFeature(feature) + } + onManualVerify={() => + handleManualVerify(feature) + } + onMoveBackToInProgress={() => + handleMoveBackToInProgress(feature) + } + onFollowUp={() => handleOpenFollowUp(feature)} + onCommit={() => handleCommitFeature(feature)} + onRevert={() => handleRevertFeature(feature)} + onMerge={() => handleMergeFeature(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes( + feature.id + )} + shortcutKey={shortcutKey} + opacity={backgroundSettings.cardOpacity} + glassmorphism={ + backgroundSettings.cardGlassmorphism + } + cardBorderEnabled={ + backgroundSettings.cardBorderEnabled + } + cardBorderOpacity={ + backgroundSettings.cardBorderOpacity + } + /> + ); + })} + + + ); + })} +
- - {activeFeature && ( - - - - {activeFeature.description} - - - {activeFeature.category} - - - - )} - - -
+ + {activeFeature && ( + + + + {activeFeature.description} + + + {activeFeature.category} + + + + )} + +
+
+ ); + })()}
+ {/* Board Background Modal */} + + {/* Add Feature Dialog */} (null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [currentTime, setCurrentTime] = useState(() => Date.now()); const { kanbanCardDetailLevel } = useAppStore(); // Check if feature has worktree @@ -148,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({ kanbanCardDetailLevel === "detailed"; const showAgentInfo = kanbanCardDetailLevel === "detailed"; + // Helper to check if "just finished" badge should be shown (within 2 minutes) + const isJustFinished = useMemo(() => { + if ( + !feature.justFinishedAt || + feature.status !== "waiting_approval" || + feature.error + ) { + return false; + } + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return currentTime - finishedTime < twoMinutes; + }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); + + // Update current time periodically to check if badge should be hidden + useEffect(() => { + if (!feature.justFinishedAt || feature.status !== "waiting_approval") { + return; + } + + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + const timeRemaining = twoMinutes - (currentTime - finishedTime); + + if (timeRemaining <= 0) { + // Already past 2 minutes + return; + } + + // Update time every second to check if 2 minutes have passed + const interval = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + + return () => clearInterval(interval); + }, [feature.justFinishedAt, feature.status, currentTime]); + // Load context file for in_progress, waiting_approval, and verified features useEffect(() => { const loadContext = async () => { @@ -184,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({ } else { // Fallback to direct file read for backward compatibility const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`; - const result = await api.readFile(contextPath); + const result = await api.readFile(contextPath); - if (result.success && result.content) { - const info = parseAgentContext(result.content); - setAgentInfo(info); + if (result.success && result.content) { + const info = parseAgentContext(result.content); + setAgentInfo(info); } } } catch { @@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({ const style = { transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : undefined, }; + // Calculate border style based on enabled state and opacity + const borderStyle: React.CSSProperties = { ...style }; + if (!cardBorderEnabled) { + (borderStyle as Record).borderWidth = "0px"; + (borderStyle as Record).borderColor = "transparent"; + } else if (cardBorderOpacity !== 100) { + // Apply border opacity using color-mix to blend the border color with transparent + // The --border variable uses oklch format, so we use color-mix in oklch space + // Ensure border width is set (1px is the default Tailwind border width) + (borderStyle as Record).borderWidth = "1px"; + ( + borderStyle as Record + ).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; + } + return ( + {/* Background overlay with opacity - only affects background, not content */} + {!isDragging && ( +
+ )} {/* Skip Tests indicator badge */} {feature.skipTests && !feature.error && (
Errored
)} - {/* Just Finished indicator badge - shows when agent just completed work */} - {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( + {/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */} + {isJustFinished && (
- Done + Fresh Baked
)} {/* Branch badge - show when feature has a worktree */} @@ -317,18 +404,22 @@ export const KanbanCard = memo(function KanbanCard({ "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "bg-purple-500/20 border border-purple-500/50 text-purple-400", // Position below other badges if present, otherwise use normal position - feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval") + feature.error || feature.skipTests || isJustFinished ? "top-8 left-2" : "top-2 left-2" )} data-testid={`branch-badge-${feature.id}`} > - {feature.branchName?.replace("feature/", "")} + + {feature.branchName?.replace("feature/", "")} +
-

{feature.branchName}

+

+ {feature.branchName} +

@@ -337,9 +428,11 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout // Add extra top padding when badges are present to prevent text overlap - (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10", + (feature.skipTests || feature.error || isJustFinished) && "pt-10", // Add even more top padding when both badges and branch are shown - hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" + hasWorktree && + (feature.skipTests || feature.error || isJustFinished) && + "pt-14" )} > {isCurrentAutoTask && ( @@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({ ) : ( )} - {step} + + {step} + ))} {feature.steps.length > 3 && ( @@ -565,7 +660,8 @@ export const KanbanCard = memo(function KanbanCard({ todo.status === "completed" && "text-muted-foreground line-through", todo.status === "in_progress" && "text-amber-400", - todo.status === "pending" && "text-foreground-secondary" + todo.status === "pending" && + "text-foreground-secondary" )} > {todo.content} @@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({ Implementation Summary - + {(() => { - const displayText = feature.description || feature.summary || "No description"; + const displayText = + feature.description || feature.summary || "No description"; return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText; @@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({ Revert Changes - This will discard all changes made by the agent and move the feature back to the backlog. + This will discard all changes made by the agent and move the + feature back to the backlog. {feature.branchName && ( - Branch {feature.branchName} will be deleted. + Branch{" "} + + {feature.branchName} + {" "} + will be deleted. )} diff --git a/apps/app/src/components/views/kanban-column.tsx b/apps/app/src/components/views/kanban-column.tsx index cbffc051..e9a76a79 100644 --- a/apps/app/src/components/views/kanban-column.tsx +++ b/apps/app/src/components/views/kanban-column.tsx @@ -12,6 +12,9 @@ interface KanbanColumnProps { count: number; children: ReactNode; headerAction?: ReactNode; + opacity?: number; // Opacity percentage (0-100) - only affects background + showBorder?: boolean; // Whether to show column border + hideScrollbar?: boolean; // Whether to hide the column scrollbar } export const KanbanColumn = memo(function KanbanColumn({ @@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({ count, children, headerAction, + opacity = 100, + showBorder = true, + hideScrollbar = false, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); @@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
- {/* Column Header */} -
+ {/* Background layer with opacity - only this layer is affected by opacity */} +
+ + {/* Column Header - positioned above the background */} +

{title}

{headerAction} @@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
- {/* Column Content */} -
+ {/* Column Content - positioned above the background */} +
{children}
diff --git a/apps/app/src/components/views/settings-view/shared/types.ts b/apps/app/src/components/views/settings-view/shared/types.ts index e28966a6..5ad91dcc 100644 --- a/apps/app/src/components/views/settings-view/shared/types.ts +++ b/apps/app/src/components/views/settings-view/shared/types.ts @@ -29,7 +29,8 @@ export type Theme = | "gruvbox" | "catppuccin" | "onedark" - | "synthwave"; + | "synthwave" + | "red"; export type KanbanDetailLevel = "minimal" | "standard" | "detailed"; diff --git a/apps/app/src/config/theme-options.ts b/apps/app/src/config/theme-options.ts index ac8bc567..ec0a028d 100644 --- a/apps/app/src/config/theme-options.ts +++ b/apps/app/src/config/theme-options.ts @@ -5,6 +5,7 @@ import { Eclipse, Flame, Ghost, + Heart, Moon, Radio, Snowflake, @@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray = [ Icon: Radio, testId: "synthwave-mode-button", }, + { + value: "red", + label: "Red", + Icon: Heart, + testId: "red-mode-button", + }, ]; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 04c84bcd..ba0f4c6b 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -316,6 +316,26 @@ export class HttpApiClient implements ElectronAPI { return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath }); } + async saveBoardBackground( + data: string, + filename: string, + mimeType: string, + projectPath: string + ): Promise<{ success: boolean; path?: string; error?: string }> { + return this.post("/api/fs/save-board-background", { + data, + filename, + mimeType, + projectPath, + }); + } + + async deleteBoardBackground( + projectPath: string + ): Promise<{ success: boolean; error?: string }> { + return this.post("/api/fs/delete-board-background", { projectPath }); + } + // CLI checks - server-side async checkClaudeCli(): Promise<{ success: boolean; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 640ab47f..573027e4 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -27,7 +27,8 @@ export type ThemeMode = | "gruvbox" | "catppuccin" | "onedark" - | "synthwave"; + | "synthwave" + | "red"; export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed"; @@ -39,23 +40,39 @@ export interface ApiKeys { // Keyboard Shortcut with optional modifiers export interface ShortcutKey { - key: string; // The main key (e.g., "K", "N", "1") - shift?: boolean; // Shift key modifier - cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux - alt?: boolean; // Alt/Option key modifier + key: string; // The main key (e.g., "K", "N", "1") + shift?: boolean; // Shift key modifier + cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux + alt?: boolean; // Alt/Option key modifier } // Helper to parse shortcut string to ShortcutKey object export function parseShortcut(shortcut: string): ShortcutKey { - const parts = shortcut.split("+").map(p => p.trim()); + const parts = shortcut.split("+").map((p) => p.trim()); const result: ShortcutKey = { key: parts[parts.length - 1] }; // Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl for (let i = 0; i < parts.length - 1; i++) { const modifier = parts[i].toLowerCase(); if (modifier === "shift") result.shift = true; - else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "āŠž" || modifier === "ā—†") result.cmdCtrl = true; - else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌄") result.alt = true; + else if ( + modifier === "cmd" || + modifier === "ctrl" || + modifier === "win" || + modifier === "super" || + modifier === "⌘" || + modifier === "^" || + modifier === "āŠž" || + modifier === "ā—†" + ) + result.cmdCtrl = true; + else if ( + modifier === "alt" || + modifier === "opt" || + modifier === "option" || + modifier === "⌄" + ) + result.alt = true; } return result; @@ -67,36 +84,49 @@ export function formatShortcut(shortcut: string, forDisplay = false): string { const parts: string[] = []; // Prefer User-Agent Client Hints when available; fall back to legacy - const platform: 'darwin' | 'win32' | 'linux' = (() => { - if (typeof navigator === 'undefined') return 'linux'; + const platform: "darwin" | "win32" | "linux" = (() => { + if (typeof navigator === "undefined") return "linux"; - const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } }) - .userAgentData?.platform?.toLowerCase?.(); + const uaPlatform = ( + navigator as Navigator & { userAgentData?: { platform?: string } } + ).userAgentData?.platform?.toLowerCase?.(); const legacyPlatform = navigator.platform?.toLowerCase?.(); - const platformString = uaPlatform || legacyPlatform || ''; + const platformString = uaPlatform || legacyPlatform || ""; - if (platformString.includes('mac')) return 'darwin'; - if (platformString.includes('win')) return 'win32'; - return 'linux'; + if (platformString.includes("mac")) return "darwin"; + if (platformString.includes("win")) return "win32"; + return "linux"; })(); // Primary modifier - OS-specific if (parsed.cmdCtrl) { if (forDisplay) { - parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? 'āŠž' : 'ā—†'); + parts.push( + platform === "darwin" ? "⌘" : platform === "win32" ? "āŠž" : "ā—†" + ); } else { - parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super'); + parts.push( + platform === "darwin" ? "Cmd" : platform === "win32" ? "Win" : "Super" + ); } } // Alt/Option if (parsed.alt) { - parts.push(forDisplay ? (platform === 'darwin' ? '⌄' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt')); + parts.push( + forDisplay + ? platform === "darwin" + ? "⌄" + : "Alt" + : platform === "darwin" + ? "Opt" + : "Alt" + ); } // Shift if (parsed.shift) { - parts.push(forDisplay ? '⇧' : 'Shift'); + parts.push(forDisplay ? "⇧" : "Shift"); } parts.push(parsed.key.toUpperCase()); @@ -139,22 +169,22 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: "C", settings: "S", profiles: "M", - + // UI toggleSidebar: "`", - + // Actions // Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile) // This is intentional as they are context-specific and only active in their respective views - addFeature: "N", // Only active in board view - addContextFile: "N", // Only active in context view - startNext: "G", // Only active in board view - newSession: "N", // Only active in agent view - openProject: "O", // Global shortcut - projectPicker: "P", // Global shortcut - cyclePrevProject: "Q", // Global shortcut - cycleNextProject: "E", // Global shortcut - addProfile: "N", // Only active in profiles view + addFeature: "N", // Only active in board view + addContextFile: "N", // Only active in context view + startNext: "G", // Only active in board view + newSession: "N", // Only active in agent view + openProject: "O", // Global shortcut + projectPicker: "P", // Global shortcut + cyclePrevProject: "Q", // Global shortcut + cycleNextProject: "E", // Global shortcut + addProfile: "N", // Only active in profiles view }; export interface ImageAttachment { @@ -246,7 +276,7 @@ export interface Feature { // Worktree info - set when a feature is being worked on in an isolated git worktree worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch - justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval + justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) } // File tree node for project analysis @@ -303,10 +333,13 @@ export interface AppState { chatHistoryOpen: boolean; // Auto Mode (per-project state, keyed by project ID) - autoModeByProject: Record; + autoModeByProject: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; // Feature IDs being worked on + } + >; autoModeActivityLog: AutoModeActivity[]; maxConcurrency: number; // Maximum number of concurrent agent tasks @@ -336,11 +369,22 @@ export interface AppState { isAnalyzing: boolean; // Board Background Settings (per-project, keyed by project path) - boardBackgroundByProject: Record; + boardBackgroundByProject: Record< + string, + { + imagePath: string | null; // Path to background image in .automaker directory + cardOpacity: number; // Opacity of cards (0-100) + columnOpacity: number; // Opacity of columns (0-100) + columnBorderEnabled: boolean; // Whether to show column borders + cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards + cardBorderEnabled: boolean; // Whether to show card borders + cardBorderOpacity: number; // Opacity of card borders (0-100) + hideScrollbar: boolean; // Whether to hide the board scrollbar + } + >; + + // Theme Preview (for hover preview in theme selectors) + previewTheme: ThemeMode | null; } export interface AutoModeActivity { @@ -386,7 +430,8 @@ export interface AppActions { // Theme actions setTheme: (theme: ThemeMode) => void; setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear) - getEffectiveTheme: () => ThemeMode; // Get the effective theme (project or global) + getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set) + setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear) // Feature actions setFeatures: (features: Feature[]) => void; @@ -422,7 +467,10 @@ export interface AppActions { addRunningTask: (projectId: string, taskId: string) => void; removeRunningTask: (projectId: string, taskId: string) => void; clearRunningTasks: (projectId: string) => void; - getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] }; + getAutoModeState: (projectId: string) => { + isRunning: boolean; + runningTasks: string[]; + }; addAutoModeActivity: ( activity: Omit ) => void; @@ -462,14 +510,31 @@ export interface AppActions { clearAnalysis: () => void; // Agent Session actions - setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; + setLastSelectedSession: ( + projectPath: string, + sessionId: string | null + ) => void; getLastSelectedSession: (projectPath: string) => string | null; // Board Background actions setBoardBackground: (projectPath: string, imagePath: string | null) => void; setCardOpacity: (projectPath: string, opacity: number) => void; setColumnOpacity: (projectPath: string, opacity: number) => void; - getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void; + getBoardBackground: (projectPath: string) => { + imagePath: string | null; + cardOpacity: number; + columnOpacity: number; + columnBorderEnabled: boolean; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; + hideScrollbar: boolean; + }; + setCardGlassmorphism: (projectPath: string, enabled: boolean) => void; + setCardBorderEnabled: (projectPath: string, enabled: boolean) => void; + setCardBorderOpacity: (projectPath: string, opacity: number) => void; + setHideScrollbar: (projectPath: string, hide: boolean) => void; clearBoardBackground: (projectPath: string) => void; // Reset @@ -481,7 +546,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: "profile-heavy-task", name: "Heavy Task", - description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.", + description: + "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.", model: "opus", thinkingLevel: "ultrathink", provider: "claude", @@ -491,7 +557,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: "profile-balanced", name: "Balanced", - description: "Claude Sonnet with medium thinking for typical development tasks.", + description: + "Claude Sonnet with medium thinking for typical development tasks.", model: "sonnet", thinkingLevel: "medium", provider: "claude", @@ -574,6 +641,7 @@ const initialState: AppState = { projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, + previewTheme: null, }; export const useAppStore = create()( @@ -699,7 +767,9 @@ export const useAppStore = create()( // Add to project history (MRU order) const currentHistory = get().projectHistory; // Remove this project if it's already in history - const filteredHistory = currentHistory.filter((id) => id !== project.id); + const filteredHistory = currentHistory.filter( + (id) => id !== project.id + ); // Add to the front (most recent) const newHistory = [project.id, ...filteredHistory]; // Reset history index to 0 (current project) @@ -739,7 +809,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -764,9 +834,8 @@ export const useAppStore = create()( if (currentIndex === -1) currentIndex = 0; // Move to the previous index (going forward = lower index), wrapping around - const newIndex = currentIndex <= 0 - ? validHistory.length - 1 - : currentIndex - 1; + const newIndex = + currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1; const targetProjectId = validHistory[newIndex]; const targetProject = projects.find((p) => p.id === targetProjectId); @@ -776,7 +845,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -828,6 +897,11 @@ export const useAppStore = create()( }, getEffectiveTheme: () => { + // If preview theme is set, use it (for hover preview) + const previewTheme = get().previewTheme; + if (previewTheme) { + return previewTheme; + } const currentProject = get().currentProject; // If current project has a theme set, use it if (currentProject?.theme) { @@ -837,6 +911,8 @@ export const useAppStore = create()( return get().theme; }, + setPreviewTheme: (theme) => set({ previewTheme: theme }), + // Feature actions setFeatures: (features) => set({ features }), @@ -988,7 +1064,10 @@ export const useAppStore = create()( // Auto Mode actions (per-project) setAutoModeRunning: (projectId, running) => { const current = get().autoModeByProject; - const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; set({ autoModeByProject: { ...current, @@ -999,7 +1078,10 @@ export const useAppStore = create()( addRunningTask: (projectId, taskId) => { const current = get().autoModeByProject; - const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; if (!projectState.runningTasks.includes(taskId)) { set({ autoModeByProject: { @@ -1015,13 +1097,18 @@ export const useAppStore = create()( removeRunningTask: (projectId, taskId) => { const current = get().autoModeByProject; - const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; set({ autoModeByProject: { ...current, [projectId]: { ...projectState, - runningTasks: projectState.runningTasks.filter((id) => id !== taskId), + runningTasks: projectState.runningTasks.filter( + (id) => id !== taskId + ), }, }, }); @@ -1029,7 +1116,10 @@ export const useAppStore = create()( clearRunningTasks: (projectId) => { const current = get().autoModeByProject; - const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; set({ autoModeByProject: { ...current, @@ -1170,7 +1260,16 @@ export const useAppStore = create()( // Board Background actions setBoardBackground: (projectPath, imagePath) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1184,7 +1283,16 @@ export const useAppStore = create()( setCardOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1198,7 +1306,16 @@ export const useAppStore = create()( setColumnOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1212,18 +1329,153 @@ export const useAppStore = create()( getBoardBackground: (projectPath) => { const settings = get().boardBackgroundByProject[projectPath]; - return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + return ( + settings || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + ); }, - clearBoardBackground: (projectPath) => { + setColumnBorderEnabled: (projectPath, enabled) => { const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, [projectPath]: { - imagePath: null, - cardOpacity: 100, - columnOpacity: 100, + ...existing, + columnBorderEnabled: enabled, + }, + }, + }); + }, + + setCardGlassmorphism: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardGlassmorphism: enabled, + }, + }, + }); + }, + + setCardBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderEnabled: enabled, + }, + }, + }); + }, + + setCardBorderOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderOpacity: opacity, + }, + }, + }); + }, + + setHideScrollbar: (projectPath, hide) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + hideScrollbar: hide, + }, + }, + }); + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath: null, // Only clear the image, preserve other settings }, }, }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fa485bd5..de7c7240 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -32,6 +32,7 @@ import { createWorkspaceRoutes } from "./routes/workspace.js"; import { createTemplatesRoutes } from "./routes/templates.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; +import { AutoModeService } from "./services/auto-mode-service.js"; // Load environment variables dotenv.config(); @@ -87,6 +88,7 @@ const events: EventEmitter = createEventEmitter(); // Create services const agentService = new AgentService(DATA_DIR, events); const featureLoader = new FeatureLoader(); +const autoModeService = new AutoModeService(events); // Initialize services (async () => { @@ -104,14 +106,14 @@ app.use("/api/fs", createFsRoutes(events)); app.use("/api/agent", createAgentRoutes(agentService, events)); app.use("/api/sessions", createSessionsRoutes(agentService)); app.use("/api/features", createFeaturesRoutes(featureLoader)); -app.use("/api/auto-mode", createAutoModeRoutes(events)); +app.use("/api/auto-mode", createAutoModeRoutes(autoModeService)); app.use("/api/worktree", createWorktreeRoutes()); app.use("/api/git", createGitRoutes()); app.use("/api/setup", createSetupRoutes()); app.use("/api/suggestions", createSuggestionsRoutes(events)); app.use("/api/models", createModelsRoutes()); app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); -app.use("/api/running-agents", createRunningAgentsRoutes()); +app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService)); app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); diff --git a/apps/server/src/routes/auto-mode.ts b/apps/server/src/routes/auto-mode.ts index 408b0d96..cd6bfcb0 100644 --- a/apps/server/src/routes/auto-mode.ts +++ b/apps/server/src/routes/auto-mode.ts @@ -5,12 +5,10 @@ */ import { Router, type Request, type Response } from "express"; -import type { EventEmitter } from "../lib/events.js"; -import { AutoModeService } from "../services/auto-mode-service.js"; +import type { AutoModeService } from "../services/auto-mode-service.js"; -export function createAutoModeRoutes(events: EventEmitter): Router { +export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); - const autoModeService = new AutoModeService(events); // Start auto mode loop router.post("/start", async (req: Request, res: Response) => { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ffb8b171..c43fad2e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -92,7 +92,11 @@ export class AutoModeService { } private async runAutoLoop(): Promise { - while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) { + while ( + this.autoLoopRunning && + this.autoLoopAbortController && + !this.autoLoopAbortController.signal.aborted + ) { try { // Check if we have capacity if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { @@ -101,7 +105,9 @@ export class AutoModeService { } // Load pending features - const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); + const pendingFeatures = await this.loadPendingFeatures( + this.config!.projectPath + ); if (pendingFeatures.length === 0) { this.emitAutoModeEvent("auto_mode_complete", { @@ -112,7 +118,9 @@ export class AutoModeService { } // Find a feature not currently running - const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); + const nextFeature = pendingFeatures.find( + (f) => !this.runningFeatures.has(f.id) + ); if (nextFeature) { // Start feature execution in background @@ -171,7 +179,11 @@ export class AutoModeService { // Setup worktree if enabled if (useWorktrees) { - worktreePath = await this.setupWorktree(projectPath, featureId, branchName); + worktreePath = await this.setupWorktree( + projectPath, + featureId, + branchName + ); } const workDir = worktreePath || projectPath; @@ -190,7 +202,11 @@ export class AutoModeService { this.emitAutoModeEvent("auto_mode_feature_start", { featureId, projectPath, - feature: { id: featureId, title: "Loading...", description: "Feature is starting" }, + feature: { + id: featureId, + title: "Loading...", + description: "Feature is starting", + }, }); try { @@ -219,12 +235,18 @@ export class AutoModeService { await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); + await this.updateFeatureStatus( + projectPath, + featureId, + "waiting_approval" + ); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, passes: true, - message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`, + message: `Feature completed in ${Math.round( + (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + )}s`, projectPath, }); } catch (error) { @@ -293,7 +315,12 @@ export class AutoModeService { if (hasContext) { // Load previous context and continue const context = await fs.readFile(contextPath, "utf-8"); - return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + return this.executeFeatureWithContext( + projectPath, + featureId, + context, + useWorktrees + ); } // No context, start fresh @@ -316,7 +343,12 @@ export class AutoModeService { const abortController = new AbortController(); // Check if worktree exists - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -379,7 +411,11 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent("auto_mode_feature_start", { featureId, projectPath, - feature: feature || { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) }, + feature: feature || { + id: featureId, + title: "Follow-up", + description: prompt.substring(0, 100), + }, }); try { @@ -472,7 +508,11 @@ Address the follow-up instructions above. Review the previous work and make the await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model); // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); + await this.updateFeatureStatus( + projectPath, + featureId, + "waiting_approval" + ); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -496,8 +536,16 @@ Address the follow-up instructions above. Review the previous work and make the /** * Verify a feature's implementation */ - async verifyFeature(projectPath: string, featureId: string): Promise { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async verifyFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -516,7 +564,8 @@ Address the follow-up instructions above. Review the previous work and make the ]; let allPassed = true; - const results: Array<{ check: string; passed: boolean; output?: string }> = []; + const results: Array<{ check: string; passed: boolean; output?: string }> = + []; for (const check of verificationChecks) { try { @@ -524,7 +573,11 @@ Address the follow-up instructions above. Review the previous work and make the cwd: workDir, timeout: 120000, }); - results.push({ check: check.name, passed: true, output: stdout || stderr }); + results.push({ + check: check.name, + passed: true, + output: stdout || stderr, + }); } catch (error) { allPassed = false; results.push({ @@ -541,7 +594,9 @@ Address the follow-up instructions above. Review the previous work and make the passes: allPassed, message: allPassed ? "All verification checks passed" - : `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`, + : `Verification failed: ${ + results.find((r) => !r.passed)?.check || "Unknown" + }`, }); return allPassed; @@ -550,8 +605,16 @@ Address the follow-up instructions above. Review the previous work and make the /** * Commit feature changes */ - async commitFeature(projectPath: string, featureId: string): Promise { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async commitFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -563,7 +626,9 @@ Address the follow-up instructions above. Review the previous work and make the try { // Check for changes - const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir }); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: workDir, + }); if (!status.trim()) { return null; // No changes } @@ -581,7 +646,9 @@ Address the follow-up instructions above. Review the previous work and make the }); // Get commit hash - const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir }); + const { stdout: hash } = await execAsync("git rev-parse HEAD", { + cwd: workDir, + }); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -599,7 +666,10 @@ Address the follow-up instructions above. Review the previous work and make the /** * Check if context exists for a feature */ - async contextExists(projectPath: string, featureId: string): Promise { + async contextExists( + projectPath: string, + featureId: string + ): Promise { const contextPath = path.join( projectPath, ".automaker", @@ -626,7 +696,11 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent("auto_mode_feature_start", { featureId: analysisFeatureId, projectPath, - feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" }, + feature: { + id: analysisFeatureId, + title: "Project Analysis", + description: "Analyzing project structure", + }, }); const prompt = `Analyze this project and provide a summary of: @@ -673,7 +747,11 @@ Format your response as a structured markdown document.`; } // Save analysis - const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md"); + const analysisPath = path.join( + projectPath, + ".automaker", + "project-analysis.md" + ); await fs.mkdir(path.dirname(analysisPath), { recursive: true }); await fs.writeFile(analysisPath, analysisResult); @@ -767,7 +845,10 @@ Format your response as a structured markdown document.`; return worktreePath; } - private async loadFeature(projectPath: string, featureId: string): Promise { + private async loadFeature( + projectPath: string, + featureId: string + ): Promise { const featurePath = path.join( projectPath, ".automaker", @@ -802,12 +883,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); - // Set justFinished flag when moving to waiting_approval (agent just completed) + // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) + // Badge will show for 2 minutes after this timestamp if (status === "waiting_approval") { - feature.justFinished = true; + feature.justFinishedAt = new Date().toISOString(); } else { - // Clear the flag when moving to other statuses - feature.justFinished = false; + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { @@ -824,7 +906,11 @@ Format your response as a structured markdown document.`; for (const entry of entries) { if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, "feature.json"); + const featurePath = path.join( + featuresDir, + entry.name, + "feature.json" + ); try { const data = await fs.readFile(featurePath, "utf-8"); const feature = JSON.parse(data); @@ -940,7 +1026,13 @@ When done, summarize what you implemented and any notes for the developer.`; // Execute via provider const stream = provider.executeQuery(options); let responseText = ""; - const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md"); + const outputPath = path.join( + workDir, + ".automaker", + "features", + featureId, + "agent-output.md" + ); for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) { From 75b73c55e0cc4d496fd3a91aa4ccc33b639e1a32 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:42:43 -0500 Subject: [PATCH 11/47] feat: introduce marketing mode and update sidebar display - Added a new configuration flag `IS_MARKETING` to toggle marketing mode. - Updated the sidebar component to conditionally display the marketing URL when in marketing mode. - Refactored event type naming for consistency in the sidebar logic. - Cleaned up formatting in the HttpApiClient for improved readability. --- apps/app/src/components/layout/sidebar.tsx | 13 +- apps/app/src/config/app-config.ts | 6 + apps/app/src/lib/http-api-client.ts | 132 ++++++++++++++++----- 3 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 apps/app/src/config/app-config.ts diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 82e46044..8476483c 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -4,6 +4,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { useAppStore, formatShortcut } from "@/store/app-store"; import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; +import { IS_MARKETING } from "@/config/app-config"; import { FolderOpen, Plus, @@ -366,7 +367,7 @@ export function Sidebar() { if ( event.type === "auto_mode_feature_complete" || event.type === "auto_mode_error" || - event.type === "auto_mode_feature_started" + event.type === "auto_mode_feature_start" ) { const fetchRunningAgentsCount = async () => { try { @@ -853,7 +854,15 @@ export function Sidebar() { sidebarOpen ? "hidden lg:block" : "hidden" )} > - Automaker + {IS_MARKETING ? ( + <> + https://automaker.app + + ) : ( + <> + Automaker + + )}
{/* Bug Report Button */} diff --git a/apps/app/src/config/app-config.ts b/apps/app/src/config/app-config.ts new file mode 100644 index 00000000..6755a303 --- /dev/null +++ b/apps/app/src/config/app-config.ts @@ -0,0 +1,6 @@ +/** + * Marketing mode flag + * When set to true, displays "https://automaker.app" with "maker" in theme color + */ + +export const IS_MARKETING = process.env.NEXT_PUBLIC_IS_MARKETING === "true"; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index ba0f4c6b..ed5377bb 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -33,7 +33,6 @@ import type { } from "@/types/electron"; import { getGlobalFileBrowser } from "@/contexts/file-browser-context"; - // Server URL - configurable via environment variable const getServerUrl = (): string => { if (typeof window !== "undefined") { @@ -43,7 +42,6 @@ const getServerUrl = (): string => { return "http://localhost:3008"; }; - // Get API key from environment variable const getApiKey = (): string | null => { if (typeof window !== "undefined") { @@ -76,7 +74,10 @@ export class HttpApiClient implements ElectronAPI { } private connectWebSocket(): void { - if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { + if ( + this.isConnecting || + (this.ws && this.ws.readyState === WebSocket.OPEN) + ) { return; } @@ -103,7 +104,10 @@ export class HttpApiClient implements ElectronAPI { callbacks.forEach((cb) => cb(data.payload)); } } catch (error) { - console.error("[HttpApiClient] Failed to parse WebSocket message:", error); + console.error( + "[HttpApiClient] Failed to parse WebSocket message:", + error + ); } }; @@ -130,7 +134,10 @@ export class HttpApiClient implements ElectronAPI { } } - private subscribeToEvent(type: EventType, callback: EventCallback): () => void { + private subscribeToEvent( + type: EventType, + callback: EventCallback + ): () => void { if (!this.eventCallbacks.has(type)) { this.eventCallbacks.set(type, new Set()); } @@ -196,7 +203,9 @@ export class HttpApiClient implements ElectronAPI { return result.status === "ok" ? "pong" : "error"; } - async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> { + async openExternalLink( + url: string + ): Promise<{ success: boolean; error?: string }> { // Open in new tab window.open(url, "_blank", "noopener,noreferrer"); return { success: true }; @@ -301,7 +310,9 @@ export class HttpApiClient implements ElectronAPI { async getPath(name: string): Promise { // Server provides data directory if (name === "userData") { - const result = await this.get<{ dataDir: string }>("/api/health/detailed"); + const result = await this.get<{ dataDir: string }>( + "/api/health/detailed" + ); return result.dataDir || "/data"; } return `/data/${name}`; @@ -313,7 +324,12 @@ export class HttpApiClient implements ElectronAPI { mimeType: string, projectPath?: string ): Promise { - return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath }); + return this.post("/api/fs/save-image", { + data, + filename, + mimeType, + projectPath, + }); } async saveBoardBackground( @@ -464,14 +480,19 @@ export class HttpApiClient implements ElectronAPI { output?: string; }> => this.post("/api/setup/auth-claude"), - authCodex: (apiKey?: string): Promise<{ + authCodex: ( + apiKey?: string + ): Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string; }> => this.post("/api/setup/auth-codex", { apiKey }), - storeApiKey: (provider: string, apiKey: string): Promise<{ + storeApiKey: ( + provider: string, + apiKey: string + ): Promise<{ success: boolean; error?: string; }> => this.post("/api/setup/store-api-key", { provider, apiKey }), @@ -483,7 +504,9 @@ export class HttpApiClient implements ElectronAPI { hasGoogleKey: boolean; }> => this.get("/api/setup/api-keys"), - configureCodexMcp: (projectPath: string): Promise<{ + configureCodexMcp: ( + projectPath: string + ): Promise<{ success: boolean; configPath?: string; error?: string; @@ -516,8 +539,11 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/features/get", { projectPath, featureId }), create: (projectPath: string, feature: Feature) => this.post("/api/features/create", { projectPath, feature }), - update: (projectPath: string, featureId: string, updates: Partial) => - this.post("/api/features/update", { projectPath, featureId, updates }), + update: ( + projectPath: string, + featureId: string, + updates: Partial + ) => this.post("/api/features/update", { projectPath, featureId, updates }), delete: (projectPath: string, featureId: string) => this.post("/api/features/delete", { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => @@ -534,8 +560,16 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/auto-mode/stop-feature", { featureId }), status: (projectPath?: string) => this.post("/api/auto-mode/status", { projectPath }), - runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => - this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }), + runFeature: ( + projectPath: string, + featureId: string, + useWorktrees?: boolean + ) => + this.post("/api/auto-mode/run-feature", { + projectPath, + featureId, + useWorktrees, + }), verifyFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), resumeFeature: (projectPath: string, featureId: string) => @@ -559,7 +593,10 @@ export class HttpApiClient implements ElectronAPI { commitFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/commit-feature", { projectPath, featureId }), onEvent: (callback: (event: AutoModeEvent) => void) => { - return this.subscribeToEvent("auto-mode:event", callback as EventCallback); + return this.subscribeToEvent( + "auto-mode:event", + callback as EventCallback + ); }, }; @@ -578,7 +615,11 @@ export class HttpApiClient implements ElectronAPI { getDiffs: (projectPath: string, featureId: string) => this.post("/api/worktree/diffs", { projectPath, featureId }), getFileDiff: (projectPath: string, featureId: string, filePath: string) => - this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }), + this.post("/api/worktree/file-diff", { + projectPath, + featureId, + filePath, + }), }; // Git API @@ -596,20 +637,30 @@ export class HttpApiClient implements ElectronAPI { stop: () => this.post("/api/suggestions/stop"), status: () => this.get("/api/suggestions/status"), onEvent: (callback: (event: SuggestionsEvent) => void) => { - return this.subscribeToEvent("suggestions:event", callback as EventCallback); + return this.subscribeToEvent( + "suggestions:event", + callback as EventCallback + ); }, }; // Spec Regeneration API specRegeneration: SpecRegenerationAPI = { - create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) => + create: ( + projectPath: string, + projectOverview: string, + generateFeatures?: boolean + ) => this.post("/api/spec-regeneration/create", { projectPath, projectOverview, generateFeatures, }), generate: (projectPath: string, projectDefinition: string) => - this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }), + this.post("/api/spec-regeneration/generate", { + projectPath, + projectDefinition, + }), generateFeatures: (projectPath: string) => this.post("/api/spec-regeneration/generate-features", { projectPath }), stop: () => this.post("/api/spec-regeneration/stop"), @@ -656,7 +707,10 @@ export class HttpApiClient implements ElectronAPI { // Agent API agent = { - start: (sessionId: string, workingDirectory?: string): Promise<{ + start: ( + sessionId: string, + workingDirectory?: string + ): Promise<{ success: boolean; messages?: Message[]; error?: string; @@ -668,9 +722,16 @@ export class HttpApiClient implements ElectronAPI { workingDirectory?: string, imagePaths?: string[] ): Promise<{ success: boolean; error?: string }> => - this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }), + this.post("/api/agent/send", { + sessionId, + message, + workingDirectory, + imagePaths, + }), - getHistory: (sessionId: string): Promise<{ + getHistory: ( + sessionId: string + ): Promise<{ success: boolean; messages?: Message[]; isRunning?: boolean; @@ -690,17 +751,24 @@ export class HttpApiClient implements ElectronAPI { // Templates API templates = { - clone: (repoUrl: string, projectName: string, parentDir: string): Promise<{ + clone: ( + repoUrl: string, + projectName: string, + parentDir: string + ): Promise<{ success: boolean; projectPath?: string; projectName?: string; error?: string; - }> => this.post("/api/templates/clone", { repoUrl, projectName, parentDir }), + }> => + this.post("/api/templates/clone", { repoUrl, projectName, parentDir }), }; // Sessions API sessions = { - list: (includeArchived?: boolean): Promise<{ + list: ( + includeArchived?: boolean + ): Promise<{ success: boolean; sessions?: SessionListItem[]; error?: string; @@ -730,13 +798,19 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; error?: string }> => this.put(`/api/sessions/${sessionId}`, { name, tags }), - archive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + archive: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/archive`, {}), - unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + unarchive: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/unarchive`, {}), - delete: (sessionId: string): Promise<{ success: boolean; error?: string }> => + delete: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; } From 7e3f77cb38cf217c88227ba7719b74c3e204bbd4 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:51:39 -0500 Subject: [PATCH 12/47] feat: add video demo section to marketing page - Introduced a new video demo section to showcase features with an embedded video player. - Styled the video container for responsive design and improved aesthetics. - Added media queries for better display on smaller screens. --- apps/marketing/public/index.html | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/marketing/public/index.html b/apps/marketing/public/index.html index 3f9a6336..2aab9346 100644 --- a/apps/marketing/public/index.html +++ b/apps/marketing/public/index.html @@ -357,6 +357,50 @@ .download-subtitle a:hover { text-decoration: underline; } + + /* Video Demo Section */ + .video-demo { + margin-top: 3rem; + max-width: 900px; + margin-left: auto; + margin-right: auto; + padding: 0 2rem; + } + + .video-container { + position: relative; + margin-left: -2rem; + margin-right: -2rem; + width: calc(100% + 4rem); + padding-bottom: 66.67%; /* Taller aspect ratio to show more height */ + background: rgba(30, 41, 59, 0.5); + border-radius: 1rem; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + } + + .video-container video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + + @media (max-width: 768px) { + .video-demo { + margin-top: 2rem; + padding: 0 1rem; + } + + .video-container { + margin-left: -1rem; + margin-right: -1rem; + width: calc(100% + 2rem); + } + } @@ -382,6 +426,15 @@ Get Started
+
+
+ +
+
+