From c21a298e07df5820dd8c64b8b390f0c4f01bfb48 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sat, 13 Dec 2025 21:53:55 -0500 Subject: [PATCH 1/6] refactor: improve ClaudeProvider query execution and message handling - Enhanced the executeQuery method to better handle conversation history and user messages, ensuring compliance with SDK requirements. - Introduced a default tools array for allowedTools, simplifying the options setup. - Updated the getAvailableModels method to use type assertions for model tiers and ensured proper return type with TypeScript's satisfies operator. - Added error handling during query execution to log and propagate errors effectively. --- apps/server/src/providers/claude-provider.ts | 145 +++++++++++++------ 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index c1456c63..cb44811d 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -7,7 +7,10 @@ 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 { + convertHistoryToMessages, + normalizeContentBlocks, +} from "../lib/conversation-utils.js"; import type { ExecuteOptions, ProviderMessage, @@ -23,7 +26,9 @@ export class ClaudeProvider extends BaseProvider { /** * Execute a query using Claude Agent SDK */ - async *executeQuery(options: ExecuteOptions): AsyncGenerator { + async *executeQuery( + options: ExecuteOptions + ): AsyncGenerator { const { prompt, model, @@ -36,21 +41,24 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options + const defaultTools = [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "WebSearch", + "WebFetch", + ]; + const toolsToUse = allowedTools || defaultTools; + const sdkOptions: Options = { model, systemPrompt, maxTurns, cwd, - allowedTools: allowedTools || [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ], + allowedTools: toolsToUse, permissionMode: "acceptEdits", sandbox: { enabled: true, @@ -60,32 +68,68 @@ export class ClaudeProvider extends BaseProvider { }; // Build prompt payload with conversation history - let promptPayload: string | AsyncGenerator; + let promptPayload: string | AsyncGenerator | Array; if (conversationHistory && conversationHistory.length > 0) { // Multi-turn conversation with history - promptPayload = (async function* () { - // Yield history messages using utility - const historyMessages = convertHistoryToMessages(conversationHistory); - for (const msg of historyMessages) { - yield msg; - } + // Convert history to SDK message format + // Note: When using async generator, SDK only accepts SDKUserMessage (type: 'user') + // So we filter to only include user messages to avoid SDK errors + const historyMessages = convertHistoryToMessages(conversationHistory); + const hasAssistantMessages = historyMessages.some( + (msg) => msg.type === "assistant" + ); - // Yield current prompt - yield { - type: "user" as const, - session_id: "", - message: { - role: "user" as const, - content: normalizeContentBlocks(prompt), - }, - parent_tool_use_id: null, - }; - })(); + if (hasAssistantMessages) { + // If we have assistant messages, use async generator but filter to only user messages + // This maintains conversation flow while respecting SDK type constraints + promptPayload = (async function* () { + // Filter to only user messages - SDK async generator only accepts SDKUserMessage + const userHistoryMessages = historyMessages.filter( + (msg) => msg.type === "user" + ); + for (const msg of userHistoryMessages) { + yield msg; + } + + // Yield current prompt + const normalizedPrompt = normalizeContentBlocks(prompt); + const currentPrompt = { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: normalizedPrompt, + }, + parent_tool_use_id: null, + }; + yield currentPrompt; + })(); + } else { + // Only user messages in history - can use async generator normally + promptPayload = (async function* () { + for (const msg of historyMessages) { + yield msg; + } + + // Yield current prompt + const normalizedPrompt = normalizeContentBlocks(prompt); + const currentPrompt = { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: normalizedPrompt, + }, + parent_tool_use_id: null, + }; + yield currentPrompt; + })(); + } } else if (Array.isArray(prompt)) { // Multi-part prompt (with images) - no history promptPayload = (async function* () { - yield { + const multiPartPrompt = { type: "user" as const, session_id: "", message: { @@ -94,6 +138,7 @@ export class ClaudeProvider extends BaseProvider { }, parent_tool_use_id: null, }; + yield multiPartPrompt; })(); } else { // Simple text prompt - no history @@ -101,11 +146,19 @@ export class ClaudeProvider extends BaseProvider { } // Execute via Claude Agent SDK - const stream = query({ prompt: promptPayload, options: sdkOptions }); + try { + 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; + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + yield msg as ProviderMessage; + } + } catch (error) { + console.error( + "[ClaudeProvider] executeQuery() error during execution:", + error + ); + throw error; } } @@ -114,22 +167,25 @@ export class ClaudeProvider extends BaseProvider { */ 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; + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + const hasApiKey = hasAnthropicKey || hasOAuthToken; - return { + const status: InstallationStatus = { installed: true, method: "sdk", hasApiKey, authenticated: hasApiKey, }; + + return status; } /** * Get available Claude models */ getAvailableModels(): ModelDefinition[] { - return [ + const models = [ { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", @@ -140,7 +196,7 @@ export class ClaudeProvider extends BaseProvider { maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "premium", + tier: "premium" as const, default: true, }, { @@ -153,7 +209,7 @@ export class ClaudeProvider extends BaseProvider { maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "standard", + tier: "standard" as const, }, { id: "claude-3-5-sonnet-20241022", @@ -165,7 +221,7 @@ export class ClaudeProvider extends BaseProvider { maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "standard", + tier: "standard" as const, }, { id: "claude-3-5-haiku-20241022", @@ -177,9 +233,10 @@ export class ClaudeProvider extends BaseProvider { maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "basic", + tier: "basic" as const, }, - ]; + ] satisfies ModelDefinition[]; + return models; } /** From 7f5cdc03454369e676014ace92e28871c59657f7 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sat, 13 Dec 2025 22:02:30 -0500 Subject: [PATCH 2/6] chore: update package.json and refactor terminal WebSocket connection handling - Added a postinstall script in package.json to set permissions for spawn-helper on macOS. - Refactored the terminal WebSocket connection handling in index.ts for improved readability and consistency. - Enhanced error logging and connection management in the terminal service. - Cleaned up formatting and indentation across multiple files for better code clarity. --- apps/server/src/index.ts | 258 ++++++++++++---------- apps/server/src/services/agent-service.ts | 32 ++- package.json | 1 + 3 files changed, 165 insertions(+), 126 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e55f55bf..59ebffa7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -30,7 +30,12 @@ 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 { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js"; +import { + createTerminalRoutes, + validateTerminalToken, + isTerminalEnabled, + isTerminalPasswordRequired, +} from "./routes/terminal.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; import { AutoModeService } from "./services/auto-mode-service.js"; @@ -64,7 +69,9 @@ if (!hasAnthropicKey && !hasOAuthToken) { ╚═══════════════════════════════════════════════════════════════════════╝ `); } else if (hasOAuthToken) { - console.log("[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)"); + console.log( + "[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)" + ); } else { console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)"); } @@ -130,7 +137,10 @@ const terminalService = getTerminalService(); // Handle HTTP upgrade requests manually to route to correct WebSocket server server.on("upgrade", (request, socket, head) => { - const { pathname } = new URL(request.url || "", `http://${request.headers.host}`); + const { pathname } = new URL( + request.url || "", + `http://${request.headers.host}` + ); if (pathname === "/api/events") { wss.handleUpgrade(request, socket, head, (ws) => { @@ -171,139 +181,153 @@ wss.on("connection", (ws: WebSocket) => { const terminalConnections: Map> = new Map(); // Terminal WebSocket connection handler -terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => { - // Parse URL to get session ID and token - const url = new URL(req.url || "", `http://${req.headers.host}`); - const sessionId = url.searchParams.get("sessionId"); - const token = url.searchParams.get("token"); +terminalWss.on( + "connection", + (ws: WebSocket, req: import("http").IncomingMessage) => { + // Parse URL to get session ID and token + const url = new URL(req.url || "", `http://${req.headers.host}`); + const sessionId = url.searchParams.get("sessionId"); + const token = url.searchParams.get("token"); - console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); + console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`); - // Check if terminal is enabled - if (!isTerminalEnabled()) { - console.log("[Terminal WS] Terminal is disabled"); - ws.close(4003, "Terminal access is disabled"); - return; - } - - // Validate token if password is required - if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { - console.log("[Terminal WS] Invalid or missing token"); - ws.close(4001, "Authentication required"); - return; - } - - if (!sessionId) { - console.log("[Terminal WS] No session ID provided"); - ws.close(4002, "Session ID required"); - return; - } - - // Check if session exists - const session = terminalService.getSession(sessionId); - if (!session) { - console.log(`[Terminal WS] Session ${sessionId} not found`); - ws.close(4004, "Session not found"); - return; - } - - console.log(`[Terminal WS] Client connected to session ${sessionId}`); - - // Track this connection - if (!terminalConnections.has(sessionId)) { - terminalConnections.set(sessionId, new Set()); - } - terminalConnections.get(sessionId)!.add(ws); - - // Subscribe to terminal data - const unsubscribeData = terminalService.onData((sid, data) => { - if (sid === sessionId && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "data", data })); + // Check if terminal is enabled + if (!isTerminalEnabled()) { + console.log("[Terminal WS] Terminal is disabled"); + ws.close(4003, "Terminal access is disabled"); + return; } - }); - // Subscribe to terminal exit - const unsubscribeExit = terminalService.onExit((sid, exitCode) => { - if (sid === sessionId && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "exit", exitCode })); - ws.close(1000, "Session ended"); + // Validate token if password is required + if ( + isTerminalPasswordRequired() && + !validateTerminalToken(token || undefined) + ) { + console.log("[Terminal WS] Invalid or missing token"); + ws.close(4001, "Authentication required"); + return; } - }); - // Handle incoming messages - ws.on("message", (message) => { - try { - const msg = JSON.parse(message.toString()); + if (!sessionId) { + console.log("[Terminal WS] No session ID provided"); + ws.close(4002, "Session ID required"); + return; + } - switch (msg.type) { - case "input": - // Write user input to terminal - terminalService.write(sessionId, msg.data); - break; + // Check if session exists + const session = terminalService.getSession(sessionId); + if (!session) { + console.log(`[Terminal WS] Session ${sessionId} not found`); + ws.close(4004, "Session not found"); + return; + } - case "resize": - // Resize terminal - if (msg.cols && msg.rows) { - terminalService.resize(sessionId, msg.cols, msg.rows); - } - break; + console.log(`[Terminal WS] Client connected to session ${sessionId}`); - case "ping": - // Respond to ping - ws.send(JSON.stringify({ type: "pong" })); - break; + // Track this connection + if (!terminalConnections.has(sessionId)) { + terminalConnections.set(sessionId, new Set()); + } + terminalConnections.get(sessionId)!.add(ws); - default: - console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); + // Subscribe to terminal data + const unsubscribeData = terminalService.onData((sid, data) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "data", data })); } - } catch (error) { - console.error("[Terminal WS] Error processing message:", error); - } - }); + }); - ws.on("close", () => { - console.log(`[Terminal WS] Client disconnected from session ${sessionId}`); - unsubscribeData(); - unsubscribeExit(); - - // Remove from connections tracking - const connections = terminalConnections.get(sessionId); - if (connections) { - connections.delete(ws); - if (connections.size === 0) { - terminalConnections.delete(sessionId); + // Subscribe to terminal exit + const unsubscribeExit = terminalService.onExit((sid, exitCode) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "exit", exitCode })); + ws.close(1000, "Session ended"); } + }); + + // Handle incoming messages + ws.on("message", (message) => { + try { + const msg = JSON.parse(message.toString()); + + switch (msg.type) { + case "input": + // Write user input to terminal + terminalService.write(sessionId, msg.data); + break; + + case "resize": + // Resize terminal + if (msg.cols && msg.rows) { + terminalService.resize(sessionId, msg.cols, msg.rows); + } + break; + + case "ping": + // Respond to ping + ws.send(JSON.stringify({ type: "pong" })); + break; + + default: + console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); + } + } catch (error) { + console.error("[Terminal WS] Error processing message:", error); + } + }); + + ws.on("close", () => { + console.log( + `[Terminal WS] Client disconnected from session ${sessionId}` + ); + unsubscribeData(); + unsubscribeExit(); + + // Remove from connections tracking + const connections = terminalConnections.get(sessionId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + terminalConnections.delete(sessionId); + } + } + }); + + ws.on("error", (error) => { + console.error(`[Terminal WS] Error on session ${sessionId}:`, error); + unsubscribeData(); + unsubscribeExit(); + }); + + // Send initial connection success + ws.send( + JSON.stringify({ + type: "connected", + sessionId, + shell: session.shell, + cwd: session.cwd, + }) + ); + + // Send scrollback buffer to replay previous output + const scrollback = terminalService.getScrollback(sessionId); + if (scrollback && scrollback.length > 0) { + ws.send( + JSON.stringify({ + type: "scrollback", + data: scrollback, + }) + ); } - }); - - ws.on("error", (error) => { - console.error(`[Terminal WS] Error on session ${sessionId}:`, error); - unsubscribeData(); - unsubscribeExit(); - }); - - // Send initial connection success - ws.send(JSON.stringify({ - type: "connected", - sessionId, - shell: session.shell, - cwd: session.cwd, - })); - - // Send scrollback buffer to replay previous output - const scrollback = terminalService.getScrollback(sessionId); - if (scrollback && scrollback.length > 0) { - ws.send(JSON.stringify({ - type: "scrollback", - data: scrollback, - })); } -}); +); // Start server server.listen(PORT, () => { const terminalStatus = isTerminalEnabled() - ? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled") + ? isTerminalPasswordRequired() + ? "enabled (password protected)" + : "enabled" : "disabled"; console.log(` ╔═══════════════════════════════════════════════════════╗ diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index b1c8cd1b..9a58940a 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -9,9 +9,7 @@ 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 { 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"; @@ -136,7 +134,10 @@ export class AgentService { filename: imageData.filename, }); } catch (error) { - console.error(`[AgentService] Failed to load image ${imagePath}:`, error); + console.error( + `[AgentService] Failed to load image ${imagePath}:`, + error + ); } } } @@ -197,7 +198,8 @@ export class AgentService { "WebFetch", ], abortController: session.abortController!, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + conversationHistory: + conversationHistory.length > 0 ? conversationHistory : undefined, }; // Build prompt content with images @@ -381,7 +383,11 @@ export class AgentService { const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { - await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8"); + await fs.writeFile( + sessionFile, + JSON.stringify(messages, null, 2), + "utf-8" + ); await this.updateSessionTimestamp(sessionId); } catch (error) { console.error("[AgentService] Failed to save session:", error); @@ -398,7 +404,11 @@ export class AgentService { } async saveMetadata(metadata: Record): Promise { - await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8"); + await fs.writeFile( + this.metadataFile, + JSON.stringify(metadata, null, 2), + "utf-8" + ); } async updateSessionTimestamp(sessionId: string): Promise { @@ -418,7 +428,8 @@ export class AgentService { } return sessions.sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } @@ -505,7 +516,10 @@ export class AgentService { return true; } - private emitAgentEvent(sessionId: string, data: Record): void { + private emitAgentEvent( + sessionId: string, + data: Record + ): void { this.events.emit("agent:stream", { sessionId, ...data }); } diff --git a/package.json b/package.json index 1d1e4b85..6ef56317 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "libs/*" ], "scripts": { + "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\"", "dev": "npm run dev --workspace=apps/app", "dev:web": "npm run dev:web --workspace=apps/app", "dev:electron": "npm run dev:electron --workspace=apps/app", From 13e3f05a7a6e5cb509b9e38969ad06df2d67f089 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sat, 13 Dec 2025 22:06:53 -0500 Subject: [PATCH 3/6] refactor: enhance init.sh and server startup error handling - Refactored init.sh to introduce a reusable function for killing processes on specified ports, improving code clarity and maintainability. - Added a cleanup function to ensure proper resource management on exit. - Updated server startup logic in index.ts to handle port conflicts gracefully, providing clear error messages and suggestions for resolution. - Improved logging for server status and health checks during initialization. --- apps/server/src/index.ts | 58 ++++++++++++++----- init.sh | 117 ++++++++++++++++++++++++++------------- 2 files changed, 124 insertions(+), 51 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 59ebffa7..c8b7291d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -322,25 +322,57 @@ terminalWss.on( } ); -// Start server -server.listen(PORT, () => { - const terminalStatus = isTerminalEnabled() - ? isTerminalPasswordRequired() - ? "enabled (password protected)" - : "enabled" - : "disabled"; - console.log(` +// Start server with error handling for port conflicts +const startServer = (port: number) => { + server.listen(port, () => { + const terminalStatus = isTerminalEnabled() + ? isTerminalPasswordRequired() + ? "enabled (password protected)" + : "enabled" + : "disabled"; + const portStr = port.toString().padEnd(4); + console.log(` ╔═══════════════════════════════════════════════════════╗ ║ Automaker Backend Server ║ ╠═══════════════════════════════════════════════════════╣ -║ HTTP API: http://localhost:${PORT} ║ -║ WebSocket: ws://localhost:${PORT}/api/events ║ -║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║ -║ Health: http://localhost:${PORT}/api/health ║ +║ HTTP API: http://localhost:${portStr} ║ +║ WebSocket: ws://localhost:${portStr}/api/events ║ +║ Terminal: ws://localhost:${portStr}/api/terminal/ws ║ +║ Health: http://localhost:${portStr}/api/health ║ ║ Terminal: ${terminalStatus.padEnd(37)}║ ╚═══════════════════════════════════════════════════════╝ `); -}); + }); + + server.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + console.error(` +╔═══════════════════════════════════════════════════════╗ +║ ❌ ERROR: Port ${port} is already in use ║ +╠═══════════════════════════════════════════════════════╣ +║ Another process is using this port. ║ +║ ║ +║ To fix this, try one of: ║ +║ ║ +║ 1. Kill the process using the port: ║ +║ lsof -ti:${port} | xargs kill -9 ║ +║ ║ +║ 2. Use a different port: ║ +║ PORT=${port + 1} npm run dev:server ║ +║ ║ +║ 3. Use the init.sh script which handles this: ║ +║ ./init.sh ║ +╚═══════════════════════════════════════════════════════╝ +`); + process.exit(1); + } else { + console.error("[Server] Error starting server:", error); + process.exit(1); + } + }); +}; + +startServer(PORT); // Graceful shutdown process.on("SIGTERM", () => { diff --git a/init.sh b/init.sh index 5f4bba43..75b876aa 100755 --- a/init.sh +++ b/init.sh @@ -33,43 +33,38 @@ fi echo -e "${YELLOW}Checking Playwright browsers...${NC}" npx playwright install chromium 2>/dev/null || true +# Function to kill process on a port and wait for it to be freed +kill_port() { + local port=$1 + local pids=$(lsof -ti:$port 2>/dev/null) + + if [ -n "$pids" ]; then + echo -e "${YELLOW}Killing process(es) on port $port: $pids${NC}" + echo "$pids" | xargs kill -9 2>/dev/null || true + + # Wait for port to be freed (max 5 seconds) + local retries=0 + while [ $retries -lt 10 ]; do + if ! lsof -ti:$port >/dev/null 2>&1; then + echo -e "${GREEN}✓ Port $port is now free${NC}" + return 0 + fi + sleep 0.5 + retries=$((retries + 1)) + done + + echo -e "${RED}Warning: Port $port may still be in use${NC}" + return 1 + else + echo -e "${GREEN}✓ Port $port is available${NC}" + return 0 + fi +} + # Kill any existing processes on required ports echo -e "${YELLOW}Checking for processes on ports 3007 and 3008...${NC}" -lsof -ti:3007 | xargs kill -9 2>/dev/null || true -lsof -ti:3008 | xargs kill -9 2>/dev/null || true - -# Start the backend server -echo -e "${BLUE}Starting backend server on port 3008...${NC}" -npm run dev:server > logs/server.log 2>&1 & -SERVER_PID=$! - -echo -e "${YELLOW}Waiting for server to be ready...${NC}" - -# Wait for server health check -MAX_RETRIES=30 -RETRY_COUNT=0 -SERVER_READY=false - -while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then - SERVER_READY=true - break - fi - sleep 1 - RETRY_COUNT=$((RETRY_COUNT + 1)) - echo -n "." -done - -echo "" - -if [ "$SERVER_READY" = false ]; then - echo -e "${RED}Error: Server failed to start${NC}" - echo "Check logs/server.log for details" - kill $SERVER_PID 2>/dev/null || true - exit 1 -fi - -echo -e "${GREEN}✓ Server is ready!${NC}" +kill_port 3007 +kill_port 3008 echo "" # Prompt user for application mode @@ -81,12 +76,59 @@ echo " 2) Desktop Application (Electron)" echo "═══════════════════════════════════════════════════════" echo "" +SERVER_PID="" + +# Cleanup function +cleanup() { + echo 'Cleaning up...' + if [ -n "$SERVER_PID" ]; then + kill $SERVER_PID 2>/dev/null || true + fi + exit +} + +trap cleanup INT TERM EXIT + while true; do read -p "Enter your choice (1 or 2): " choice case $choice in 1) echo "" echo -e "${BLUE}Launching Web Application...${NC}" + + # Start the backend server (only needed for Web mode) + echo -e "${BLUE}Starting backend server on port 3008...${NC}" + mkdir -p logs + npm run dev:server > logs/server.log 2>&1 & + SERVER_PID=$! + + echo -e "${YELLOW}Waiting for server to be ready...${NC}" + + # Wait for server health check + MAX_RETRIES=30 + RETRY_COUNT=0 + SERVER_READY=false + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then + SERVER_READY=true + break + fi + sleep 1 + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo -n "." + done + + echo "" + + if [ "$SERVER_READY" = false ]; then + echo -e "${RED}Error: Server failed to start${NC}" + echo "Check logs/server.log for details" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + echo -e "${GREEN}✓ Server is ready!${NC}" echo "The application will be available at: ${GREEN}http://localhost:3007${NC}" echo "" npm run dev:web @@ -95,6 +137,8 @@ while true; do 2) echo "" echo -e "${BLUE}Launching Desktop Application...${NC}" + echo -e "${YELLOW}(Electron will start its own backend server)${NC}" + echo "" npm run dev:electron break ;; @@ -103,6 +147,3 @@ while true; do ;; esac done - -# Cleanup on exit -trap "echo 'Cleaning up...'; kill $SERVER_PID 2>/dev/null || true; exit" INT TERM EXIT From 58f466b4436bd772230cc6475e4bf7d542bfe2f2 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 14 Dec 2025 00:20:11 -0500 Subject: [PATCH 4/6] feat: update terminal shortcut and improve code formatting - Added a hasInstallScript property to package-lock.json. - Refactored the app-store.ts file for improved readability by formatting function parameters and object properties. - Updated the default terminal shortcut from "Cmd+`" to "T" and implemented migration logic for state persistence. - Incremented version number in the terminal state management to reflect breaking changes. --- apps/app/src/store/app-store.ts | 235 +++++++++++++++++++++++++------- package-lock.json | 1 + 2 files changed, 183 insertions(+), 53 deletions(-) diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index ac2231e5..4b4d947f 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -49,7 +49,9 @@ export interface ShortcutKey { } // Helper to parse shortcut string to ShortcutKey object -export function parseShortcut(shortcut: string | undefined | null): ShortcutKey { +export function parseShortcut( + shortcut: string | undefined | null +): ShortcutKey { if (!shortcut) return { key: "" }; const parts = shortcut.split("+").map((p) => p.trim()); const result: ShortcutKey = { key: parts[parts.length - 1] }; @@ -82,7 +84,10 @@ export function parseShortcut(shortcut: string | undefined | null): ShortcutKey } // Helper to format ShortcutKey to display string -export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string { +export function formatShortcut( + shortcut: string | undefined | null, + forDisplay = false +): string { if (!shortcut) return ""; const parsed = parseShortcut(shortcut); const parts: string[] = []; @@ -179,7 +184,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: "C", settings: "S", profiles: "M", - terminal: "Cmd+`", + terminal: "T", // UI toggleSidebar: "`", @@ -308,7 +313,12 @@ export interface ProjectAnalysis { // Terminal panel layout types (recursive for splits) export type TerminalPanelContent = | { type: "terminal"; sessionId: string; size?: number; fontSize?: number } - | { type: "split"; direction: "horizontal" | "vertical"; panels: TerminalPanelContent[]; size?: number }; + | { + type: "split"; + direction: "horizontal" | "vertical"; + panels: TerminalPanelContent[]; + size?: number; + }; // Terminal tab - each tab has its own layout export interface TerminalTab { @@ -600,7 +610,11 @@ export interface AppActions { // Terminal actions setTerminalUnlocked: (unlocked: boolean, token?: string) => void; setActiveTerminalSession: (sessionId: string | null) => void; - addTerminalToLayout: (sessionId: string, direction?: "horizontal" | "vertical", targetSessionId?: string) => void; + addTerminalToLayout: ( + sessionId: string, + direction?: "horizontal" | "vertical", + targetSessionId?: string + ) => void; removeTerminalFromLayout: (sessionId: string) => void; swapTerminals: (sessionId1: string, sessionId2: string) => void; clearTerminalState: () => void; @@ -610,7 +624,11 @@ export interface AppActions { setActiveTerminalTab: (tabId: string) => void; renameTerminalTab: (tabId: string, name: string) => void; moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void; - addTerminalToTab: (sessionId: string, tabId: string, direction?: "horizontal" | "vertical") => void; + addTerminalToTab: ( + sessionId: string, + tabId: string, + direction?: "horizontal" | "vertical" + ) => void; // Reset reset: () => void; @@ -1331,8 +1349,10 @@ export const useAppStore = create()( resetAIProfiles: () => { // Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults - const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map(p => p.id)); - const userProfiles = get().aiProfiles.filter(p => !p.isBuiltIn && !defaultProfileIds.has(p.id)); + 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] }); }, @@ -1528,9 +1548,17 @@ export const useAppStore = create()( }); }, - addTerminalToLayout: (sessionId, direction = "horizontal", targetSessionId) => { + addTerminalToLayout: ( + sessionId, + direction = "horizontal", + targetSessionId + ) => { const current = get().terminalState; - const newTerminal: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + const newTerminal: TerminalPanelContent = { + type: "terminal", + sessionId, + size: 50, + }; // If no tabs, create first tab if (current.tabs.length === 0) { @@ -1538,7 +1566,13 @@ export const useAppStore = create()( set({ terminalState: { ...current, - tabs: [{ id: newTabId, name: "Terminal 1", layout: { type: "terminal", sessionId, size: 100 } }], + tabs: [ + { + id: newTabId, + name: "Terminal 1", + layout: { type: "terminal", sessionId, size: 100 }, + }, + ], activeTabId: newTabId, activeSessionId: sessionId, }, @@ -1547,7 +1581,9 @@ export const useAppStore = create()( } // Add to active tab's layout - const activeTab = current.tabs.find(t => t.id === current.activeTabId); + const activeTab = current.tabs.find( + (t) => t.id === current.activeTabId + ); if (!activeTab) return; // If targetSessionId is provided, find and split that specific terminal @@ -1571,7 +1607,9 @@ export const useAppStore = create()( // It's a split - recurse into panels return { ...node, - panels: node.panels.map(p => splitTargetTerminal(p, targetId, targetDirection)), + panels: node.panels.map((p) => + splitTargetTerminal(p, targetId, targetDirection) + ), }; }; @@ -1592,7 +1630,10 @@ export const useAppStore = create()( const newSize = 100 / (node.panels.length + 1); return { ...node, - panels: [...node.panels.map(p => ({ ...p, size: newSize })), { ...newTerminal, size: newSize }], + panels: [ + ...node.panels.map((p) => ({ ...p, size: newSize })), + { ...newTerminal, size: newSize }, + ], }; } // Different direction, wrap in new split @@ -1607,12 +1648,16 @@ export const useAppStore = create()( if (!activeTab.layout) { newLayout = { type: "terminal", sessionId, size: 100 }; } else if (targetSessionId) { - newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction); + newLayout = splitTargetTerminal( + activeTab.layout, + targetSessionId, + direction + ); } else { newLayout = addToRootLayout(activeTab.layout, direction); } - const newTabs = current.tabs.map(t => + const newTabs = current.tabs.map((t) => t.id === current.activeTabId ? { ...t, layout: newLayout } : t ); @@ -1630,7 +1675,9 @@ export const useAppStore = create()( if (current.tabs.length === 0) return; // Find which tab contains this session - const findFirstTerminal = (node: TerminalPanelContent | null): string | null => { + const findFirstTerminal = ( + node: TerminalPanelContent | null + ): string | null => { if (!node) return null; if (node.type === "terminal") return node.sessionId; for (const panel of node.panels) { @@ -1640,7 +1687,9 @@ export const useAppStore = create()( return null; }; - const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + const removeAndCollapse = ( + node: TerminalPanelContent + ): TerminalPanelContent | null => { if (node.type === "terminal") { return node.sessionId === sessionId ? null : node; } @@ -1654,19 +1703,27 @@ export const useAppStore = create()( return { ...node, panels: newPanels }; }; - let newTabs = current.tabs.map(tab => { + let newTabs = current.tabs.map((tab) => { if (!tab.layout) return tab; const newLayout = removeAndCollapse(tab.layout); return { ...tab, layout: newLayout }; }); // Remove empty tabs - newTabs = newTabs.filter(tab => tab.layout !== null); + newTabs = newTabs.filter((tab) => tab.layout !== null); // Determine new active session - const newActiveTabId = newTabs.length > 0 ? (current.activeTabId && newTabs.find(t => t.id === current.activeTabId) ? current.activeTabId : newTabs[0].id) : null; + const newActiveTabId = + newTabs.length > 0 + ? current.activeTabId && + newTabs.find((t) => t.id === current.activeTabId) + ? current.activeTabId + : newTabs[0].id + : null; const newActiveSessionId = newActiveTabId - ? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null) + ? findFirstTerminal( + newTabs.find((t) => t.id === newActiveTabId)?.layout || null + ) : null; set({ @@ -1683,16 +1740,20 @@ export const useAppStore = create()( const current = get().terminalState; if (current.tabs.length === 0) return; - const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { + const swapInLayout = ( + node: TerminalPanelContent + ): TerminalPanelContent => { if (node.type === "terminal") { - if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; - if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; + if (node.sessionId === sessionId1) + return { ...node, sessionId: sessionId2 }; + if (node.sessionId === sessionId2) + return { ...node, sessionId: sessionId1 }; return node; } return { ...node, panels: node.panels.map(swapInLayout) }; }; - const newTabs = current.tabs.map(tab => ({ + const newTabs = current.tabs.map((tab) => ({ ...tab, layout: tab.layout ? swapInLayout(tab.layout) : null, })); @@ -1719,7 +1780,9 @@ export const useAppStore = create()( const current = get().terminalState; const clampedSize = Math.max(8, Math.min(32, fontSize)); - const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => { + const updateFontSize = ( + node: TerminalPanelContent + ): TerminalPanelContent => { if (node.type === "terminal") { if (node.sessionId === sessionId) { return { ...node, fontSize: clampedSize }; @@ -1729,7 +1792,7 @@ export const useAppStore = create()( return { ...node, panels: node.panels.map(updateFontSize) }; }; - const newTabs = current.tabs.map(tab => { + const newTabs = current.tabs.map((tab) => { if (!tab.layout) return tab; return { ...tab, layout: updateFontSize(tab.layout) }; }); @@ -1743,7 +1806,11 @@ export const useAppStore = create()( const current = get().terminalState; const newTabId = `tab-${Date.now()}`; const tabNumber = current.tabs.length + 1; - const newTab: TerminalTab = { id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null }; + const newTab: TerminalTab = { + id: newTabId, + name: name || `Terminal ${tabNumber}`, + layout: null, + }; set({ terminalState: { ...current, @@ -1756,14 +1823,14 @@ export const useAppStore = create()( removeTerminalTab: (tabId) => { const current = get().terminalState; - const newTabs = current.tabs.filter(t => t.id !== tabId); + const newTabs = current.tabs.filter((t) => t.id !== tabId); let newActiveTabId = current.activeTabId; let newActiveSessionId = current.activeSessionId; if (current.activeTabId === tabId) { newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; if (newActiveTabId) { - const newActiveTab = newTabs.find(t => t.id === newActiveTabId); + const newActiveTab = newTabs.find((t) => t.id === newActiveTabId); const findFirst = (node: TerminalPanelContent): string | null => { if (node.type === "terminal") return node.sessionId; for (const p of node.panels) { @@ -1772,20 +1839,27 @@ export const useAppStore = create()( } return null; }; - newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; + newActiveSessionId = newActiveTab?.layout + ? findFirst(newActiveTab.layout) + : null; } else { newActiveSessionId = null; } } set({ - terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId }, + terminalState: { + ...current, + tabs: newTabs, + activeTabId: newActiveTabId, + activeSessionId: newActiveSessionId, + }, }); }, setActiveTerminalTab: (tabId) => { const current = get().terminalState; - const tab = current.tabs.find(t => t.id === tabId); + const tab = current.tabs.find((t) => t.id === tabId); if (!tab) return; let newActiveSessionId = current.activeSessionId; @@ -1802,13 +1876,19 @@ export const useAppStore = create()( } set({ - terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId }, + terminalState: { + ...current, + activeTabId: tabId, + activeSessionId: newActiveSessionId, + }, }); }, renameTerminalTab: (tabId, name) => { const current = get().terminalState; - const newTabs = current.tabs.map(t => t.id === tabId ? { ...t, name } : t); + const newTabs = current.tabs.map((t) => + t.id === tabId ? { ...t, name } : t + ); set({ terminalState: { ...current, tabs: newTabs }, }); @@ -1818,9 +1898,13 @@ export const useAppStore = create()( const current = get().terminalState; let sourceTabId: string | null = null; - let originalTerminalNode: (TerminalPanelContent & { type: "terminal" }) | null = null; + let originalTerminalNode: + | (TerminalPanelContent & { type: "terminal" }) + | null = null; - const findTerminal = (node: TerminalPanelContent): (TerminalPanelContent & { type: "terminal" }) | null => { + const findTerminal = ( + node: TerminalPanelContent + ): (TerminalPanelContent & { type: "terminal" }) | null => { if (node.type === "terminal") { return node.sessionId === sessionId ? node : null; } @@ -1844,10 +1928,12 @@ export const useAppStore = create()( if (!sourceTabId || !originalTerminalNode) return; if (sourceTabId === targetTabId) return; - const sourceTab = current.tabs.find(t => t.id === sourceTabId); + const sourceTab = current.tabs.find((t) => t.id === sourceTabId); if (!sourceTab?.layout) return; - const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + const removeAndCollapse = ( + node: TerminalPanelContent + ): TerminalPanelContent | null => { if (node.type === "terminal") { return node.sessionId === sessionId ? null : node; } @@ -1869,21 +1955,42 @@ export const useAppStore = create()( if (targetTabId === "new") { const newTabId = `tab-${Date.now()}`; const sourceWillBeRemoved = !newSourceLayout; - const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`; + const tabName = sourceWillBeRemoved + ? sourceTab.name + : `Terminal ${current.tabs.length + 1}`; newTabs = [ ...current.tabs, - { id: newTabId, name: tabName, layout: { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize } }, + { + id: newTabId, + name: tabName, + layout: { + type: "terminal", + sessionId, + size: 100, + fontSize: originalTerminalNode.fontSize, + }, + }, ]; finalTargetTabId = newTabId; } else { - const targetTab = current.tabs.find(t => t.id === targetTabId); + const targetTab = current.tabs.find((t) => t.id === targetTabId); if (!targetTab) return; - const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50, fontSize: originalTerminalNode.fontSize }; + const terminalNode: TerminalPanelContent = { + type: "terminal", + sessionId, + size: 50, + fontSize: originalTerminalNode.fontSize, + }; let newTargetLayout: TerminalPanelContent; if (!targetTab.layout) { - newTargetLayout = { type: "terminal", sessionId, size: 100, fontSize: originalTerminalNode.fontSize }; + newTargetLayout = { + type: "terminal", + sessionId, + size: 100, + fontSize: originalTerminalNode.fontSize, + }; } else if (targetTab.layout.type === "terminal") { newTargetLayout = { type: "split", @@ -1897,15 +2004,15 @@ export const useAppStore = create()( }; } - newTabs = current.tabs.map(t => + newTabs = current.tabs.map((t) => t.id === targetTabId ? { ...t, layout: newTargetLayout } : t ); } if (!newSourceLayout) { - newTabs = newTabs.filter(t => t.id !== sourceTabId); + newTabs = newTabs.filter((t) => t.id !== sourceTabId); } else { - newTabs = newTabs.map(t => + newTabs = newTabs.map((t) => t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t ); } @@ -1922,10 +2029,14 @@ export const useAppStore = create()( addTerminalToTab: (sessionId, tabId, direction = "horizontal") => { const current = get().terminalState; - const tab = current.tabs.find(t => t.id === tabId); + const tab = current.tabs.find((t) => t.id === tabId); if (!tab) return; - const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; + const terminalNode: TerminalPanelContent = { + type: "terminal", + sessionId, + size: 50, + }; let newLayout: TerminalPanelContent; if (!tab.layout) { @@ -1941,7 +2052,10 @@ export const useAppStore = create()( const newSize = 100 / (tab.layout.panels.length + 1); newLayout = { ...tab.layout, - panels: [...tab.layout.panels.map(p => ({ ...p, size: newSize })), { ...terminalNode, size: newSize }], + panels: [ + ...tab.layout.panels.map((p) => ({ ...p, size: newSize })), + { ...terminalNode, size: newSize }, + ], }; } else { newLayout = { @@ -1952,7 +2066,7 @@ export const useAppStore = create()( } } - const newTabs = current.tabs.map(t => + const newTabs = current.tabs.map((t) => t.id === tabId ? { ...t, layout: newLayout } : t ); @@ -1971,7 +2085,7 @@ export const useAppStore = create()( }), { name: "automaker-storage", - version: 1, // Increment when making breaking changes to persisted state + version: 2, // Increment when making breaking changes to persisted state migrate: (persistedState: unknown, version: number) => { const state = persistedState as Partial; @@ -1983,6 +2097,21 @@ export const useAppStore = create()( } } + // Migration from version 1 to version 2: + // - Change terminal shortcut from "Cmd+`" to "T" + if (version <= 1) { + if ( + state.keyboardShortcuts?.terminal === "Cmd+`" || + state.keyboardShortcuts?.terminal === undefined + ) { + state.keyboardShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...state.keyboardShortcuts, + terminal: "T", + }; + } + } + return state as AppState; }, partialize: (state) => ({ diff --git a/package-lock.json b/package-lock.json index 14af81a3..1b7a5c4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "automaker", "version": "1.0.0", + "hasInstallScript": true, "workspaces": [ "apps/*", "libs/*" From b52b9ba23615b28f1cbd30b3c1123129023dfbf8 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 14 Dec 2025 00:43:52 -0500 Subject: [PATCH 5/6] feat: enhance project initialization and improve logging in auto mode service - Added a default categories.json file to the project initialization structure. - Improved code formatting and readability in the auto-mode-service.ts file by restructuring console log statements and method calls. - Updated feature status checks to include "backlog" in addition to "pending" and "ready". --- .../src/components/views/interview-view.tsx | 21 ++- .../app/src/components/views/welcome-view.tsx | 8 +- apps/app/src/hooks/use-auto-mode.ts | 106 ++++++++---- apps/app/src/lib/project-init.ts | 4 +- apps/app/src/types/electron.d.ts | 18 ++ apps/server/src/lib/security.ts | 10 +- apps/server/src/routes/fs.ts | 161 ++++++++++++------ apps/server/src/services/auto-mode-service.ts | 95 ++++++++--- 8 files changed, 311 insertions(+), 112 deletions(-) diff --git a/apps/app/src/components/views/interview-view.tsx b/apps/app/src/components/views/interview-view.tsx index 75007a8c..8fd74073 100644 --- a/apps/app/src/components/views/interview-view.tsx +++ b/apps/app/src/components/views/interview-view.tsx @@ -19,6 +19,7 @@ import { cn } from "@/lib/utils"; import { getElectronAPI } from "@/lib/electron"; import { Markdown } from "@/components/ui/markdown"; import { useFileBrowser } from "@/contexts/file-browser-context"; +import { toast } from "sonner"; interface InterviewMessage { id: string; @@ -290,7 +291,8 @@ export function InterviewView() { const handleSelectDirectory = async () => { const selectedPath = await openFileBrowser({ title: "Select Base Directory", - description: "Choose the parent directory where your new project will be created", + description: + "Choose the parent directory where your new project will be created", }); if (selectedPath) { @@ -306,12 +308,23 @@ export function InterviewView() { try { const api = getElectronAPI(); // Use platform-specific path separator - const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ? - (navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/'; + const pathSep = + typeof window !== "undefined" && (window as any).electronAPI + ? navigator.platform.indexOf("Win") !== -1 + ? "\\" + : "/" + : "/"; const fullProjectPath = `${projectPath}${pathSep}${projectName}`; // Create project directory - await api.mkdir(fullProjectPath); + const mkdirResult = await api.mkdir(fullProjectPath); + if (!mkdirResult.success) { + toast.error("Failed to create project directory", { + description: mkdirResult.error || "Unknown error occurred", + }); + setIsGenerating(false); + return; + } // Write app_spec.txt with generated content await api.writeFile( diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 21c93112..1965479c 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -236,7 +236,13 @@ export function WelcomeView() { const projectPath = `${parentDir}/${projectName}`; // Create project directory - await api.mkdir(projectPath); + const mkdirResult = await api.mkdir(projectPath); + if (!mkdirResult.success) { + toast.error("Failed to create project directory", { + description: mkdirResult.error || "Unknown error occurred", + }); + return; + } // Initialize .automaker directory with all necessary files const initResult = await initializeProject(projectPath); diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts index 6852ec64..4fac3f41 100644 --- a/apps/app/src/hooks/use-auto-mode.ts +++ b/apps/app/src/hooks/use-auto-mode.ts @@ -33,16 +33,21 @@ export function useAutoMode() { ); // Helper to look up project ID from path - const getProjectIdFromPath = useCallback((path: string): string | undefined => { - const project = projects.find(p => p.path === path); - return project?.id; - }, [projects]); + const getProjectIdFromPath = useCallback( + (path: string): string | undefined => { + const project = projects.find((p) => p.path === path); + return project?.id; + }, + [projects] + ); // Get project-specific auto mode state const projectId = currentProject?.id; const projectAutoModeState = useMemo(() => { if (!projectId) return { isRunning: false, runningTasks: [] }; - return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }; + return ( + autoModeByProject[projectId] || { isRunning: false, runningTasks: [] } + ); }, [autoModeByProject, projectId]); const isAutoModeRunning = projectAutoModeState.isRunning; @@ -62,10 +67,10 @@ export function useAutoMode() { // Events include projectPath from backend - use it to look up project ID // Fall back to current projectId if not provided in event let eventProjectId: string | undefined; - if ('projectPath' in event && event.projectPath) { + if ("projectPath" in event && event.projectPath) { eventProjectId = getProjectIdFromPath(event.projectPath); } - if (!eventProjectId && 'projectId' in event && event.projectId) { + if (!eventProjectId && "projectId" in event && event.projectId) { eventProjectId = event.projectId; } if (!eventProjectId) { @@ -74,7 +79,10 @@ export function useAutoMode() { // Skip event if we couldn't determine the project if (!eventProjectId) { - console.warn("[AutoMode] Could not determine project for event:", event); + console.warn( + "[AutoMode] Could not determine project for event:", + event + ); return; } @@ -111,20 +119,41 @@ export function useAutoMode() { } break; - case "auto_mode_complete": - // All features completed for this project + case "auto_mode_stopped": + // Auto mode was explicitly stopped (by user or error) setAutoModeRunning(eventProjectId, false); clearRunningTasks(eventProjectId); - console.log("[AutoMode] All features completed!"); + console.log("[AutoMode] Auto mode stopped"); + break; + + case "auto_mode_started": + // Auto mode started - ensure UI reflects running state + console.log("[AutoMode] Auto mode started:", event.message); + break; + + case "auto_mode_idle": + // Auto mode is running but has no pending features to pick up + // This is NOT a stop - auto mode keeps running and will pick up new features + console.log("[AutoMode] Auto mode idle - waiting for new features"); + break; + + case "auto_mode_complete": + // Legacy event - only handle if it looks like a stop (for backwards compatibility) + if (event.message === "Auto mode stopped") { + setAutoModeRunning(eventProjectId, false); + clearRunningTasks(eventProjectId); + console.log("[AutoMode] Auto mode stopped (legacy event)"); + } break; case "auto_mode_error": console.error("[AutoMode Error]", event.error); if (event.featureId && event.error) { // Check for authentication errors and provide a more helpful message - const isAuthError = event.errorType === "authentication" || - event.error.includes("Authentication failed") || - event.error.includes("Invalid API key"); + const isAuthError = + event.errorType === "authentication" || + event.error.includes("Authentication failed") || + event.error.includes("Invalid API key"); const errorMessage = isAuthError ? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.` @@ -202,11 +231,12 @@ export function useAutoMode() { if (!api?.autoMode) return; // Find all projects that have auto mode marked as running - const projectsToRestart: Array<{ projectId: string; projectPath: string }> = []; + const projectsToRestart: Array<{ projectId: string; projectPath: string }> = + []; for (const [projectId, state] of Object.entries(autoModeByProject)) { if (state.isRunning) { // Find the project path for this project ID - const project = projects.find(p => p.id === projectId); + const project = projects.find((p) => p.id === projectId); if (project) { projectsToRestart.push({ projectId, projectPath: project.path }); } @@ -216,18 +246,27 @@ export function useAutoMode() { // Restart auto mode for each project for (const { projectId, projectPath } of projectsToRestart) { console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`); - api.autoMode.start(projectPath, maxConcurrency).then(result => { - if (!result.success) { - console.error(`[AutoMode] Failed to restore auto mode for ${projectPath}:`, result.error); - // Mark as not running if we couldn't restart + api.autoMode + .start(projectPath, maxConcurrency) + .then((result) => { + if (!result.success) { + console.error( + `[AutoMode] Failed to restore auto mode for ${projectPath}:`, + result.error + ); + // Mark as not running if we couldn't restart + setAutoModeRunning(projectId, false); + } else { + console.log(`[AutoMode] Restored auto mode for ${projectPath}`); + } + }) + .catch((error) => { + console.error( + `[AutoMode] Error restoring auto mode for ${projectPath}:`, + error + ); setAutoModeRunning(projectId, false); - } else { - console.log(`[AutoMode] Restored auto mode for ${projectPath}`); - } - }).catch(error => { - console.error(`[AutoMode] Error restoring auto mode for ${projectPath}:`, error); - setAutoModeRunning(projectId, false); - }); + }); } // Only run once on mount - intentionally empty dependency array // eslint-disable-next-line react-hooks/exhaustive-deps @@ -246,11 +285,16 @@ export function useAutoMode() { throw new Error("Auto mode API not available"); } - const result = await api.autoMode.start(currentProject.path, maxConcurrency); + const result = await api.autoMode.start( + currentProject.path, + maxConcurrency + ); if (result.success) { setAutoModeRunning(currentProject.id, true); - console.log(`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`); + console.log( + `[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}` + ); } else { console.error("[AutoMode] Failed to start:", result.error); throw new Error(result.error || "Failed to start auto mode"); @@ -285,7 +329,9 @@ export function useAutoMode() { // Stopping auto mode only turns off the toggle to prevent new features // from being picked up. Running tasks will complete naturally and be // removed via the auto_mode_feature_complete event. - console.log("[AutoMode] Stopped successfully - running tasks will continue"); + console.log( + "[AutoMode] Stopped successfully - running tasks will continue" + ); } else { console.error("[AutoMode] Failed to stop:", result.error); throw new Error(result.error || "Failed to stop auto mode"); diff --git a/apps/app/src/lib/project-init.ts b/apps/app/src/lib/project-init.ts index 612af0fc..2ba184fc 100644 --- a/apps/app/src/lib/project-init.ts +++ b/apps/app/src/lib/project-init.ts @@ -29,7 +29,9 @@ const REQUIRED_STRUCTURE: { ".automaker/features", ".automaker/images", ], - files: {}, + files: { + ".automaker/categories.json": "[]", + }, }; /** diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index 6ee473bf..0a086fbd 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -203,6 +203,24 @@ export type AutoModeEvent = projectId?: string; projectPath?: string; } + | { + type: "auto_mode_stopped"; + message: string; + projectId?: string; + projectPath?: string; + } + | { + type: "auto_mode_started"; + message: string; + projectId?: string; + projectPath?: string; + } + | { + type: "auto_mode_idle"; + message: string; + projectId?: string; + projectPath?: string; + } | { type: "auto_mode_phase"; featureId: string; diff --git a/apps/server/src/lib/security.ts b/apps/server/src/lib/security.ts index eac0af00..d580cd41 100644 --- a/apps/server/src/lib/security.ts +++ b/apps/server/src/lib/security.ts @@ -26,6 +26,12 @@ export function initAllowedPaths(): void { if (dataDir) { allowedPaths.add(path.resolve(dataDir)); } + + // Always allow the workspace directory (where projects are created) + const workspaceDir = process.env.WORKSPACE_DIR; + if (workspaceDir) { + allowedPaths.add(path.resolve(workspaceDir)); + } } /** @@ -58,7 +64,9 @@ export function validatePath(filePath: string): string { const resolved = path.resolve(filePath); if (!isPathAllowed(resolved)) { - throw new Error(`Access denied: ${filePath} is not in an allowed directory`); + throw new Error( + `Access denied: ${filePath} is not in an allowed directory` + ); } return resolved; diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index ef227918..8ade7901 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -7,7 +7,11 @@ import { Router, type Request, type Response } from "express"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; +import { + validatePath, + addAllowedPath, + isPathAllowed, +} from "../lib/security.js"; import type { EventEmitter } from "../lib/events.js"; export function createFsRoutes(_events: EventEmitter): Router { @@ -69,9 +73,41 @@ export function createFsRoutes(_events: EventEmitter): Router { return; } - const resolvedPath = validatePath(dirPath); + const resolvedPath = path.resolve(dirPath); + + // Security check: allow paths in allowed directories OR within home directory + const isAllowed = (() => { + // Check if path or parent is in allowed paths + if (isPathAllowed(resolvedPath)) return true; + const parentPath = path.dirname(resolvedPath); + if (isPathAllowed(parentPath)) return true; + + // Also allow within home directory (like the /browse endpoint) + const homeDir = os.homedir(); + const normalizedHome = path.normalize(homeDir); + if ( + resolvedPath === normalizedHome || + resolvedPath.startsWith(normalizedHome + path.sep) + ) { + return true; + } + + return false; + })(); + + if (!isAllowed) { + res.status(403).json({ + success: false, + error: `Access denied: ${dirPath} is not in an allowed directory`, + }); + return; + } + await fs.mkdir(resolvedPath, { recursive: true }); + // Add the new directory to allowed paths so subsequent operations work + addAllowedPath(resolvedPath); + res.json({ success: true }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -197,7 +233,9 @@ export function createFsRoutes(_events: EventEmitter): Router { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { - res.status(400).json({ success: false, error: "Path is not a directory" }); + res + .status(400) + .json({ success: false, error: "Path is not a directory" }); return; } @@ -229,7 +267,9 @@ export function createFsRoutes(_events: EventEmitter): Router { }; if (!directoryName) { - res.status(400).json({ success: false, error: "directoryName is required" }); + res + .status(400) + .json({ success: false, error: "directoryName is required" }); return; } @@ -254,10 +294,16 @@ export function createFsRoutes(_events: EventEmitter): Router { const searchPaths: string[] = [ process.cwd(), // Current working directory process.env.HOME || process.env.USERPROFILE || "", // User home - path.join(process.env.HOME || process.env.USERPROFILE || "", "Documents"), + path.join( + process.env.HOME || process.env.USERPROFILE || "", + "Documents" + ), path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"), // Common project locations - path.join(process.env.HOME || process.env.USERPROFILE || "", "Projects"), + path.join( + process.env.HOME || process.env.USERPROFILE || "", + "Projects" + ), ].filter(Boolean); // Also check parent of current working directory @@ -275,7 +321,7 @@ export function createFsRoutes(_events: EventEmitter): Router { try { const candidatePath = path.join(searchPath, directoryName); const stats = await fs.stat(candidatePath); - + if (stats.isDirectory()) { // Verify it matches by checking for sample files if (sampleFiles && sampleFiles.length > 0) { @@ -284,8 +330,10 @@ export function createFsRoutes(_events: EventEmitter): Router { // Remove directory name prefix from sample file path const relativeFile = sampleFile.startsWith(directoryName + "/") ? sampleFile.substring(directoryName.length + 1) - : sampleFile.split("/").slice(1).join("/") || sampleFile.split("/").pop() || sampleFile; - + : sampleFile.split("/").slice(1).join("/") || + sampleFile.split("/").pop() || + sampleFile; + try { const filePath = path.join(candidatePath, relativeFile); await fs.access(filePath); @@ -294,7 +342,7 @@ export function createFsRoutes(_events: EventEmitter): Router { // File doesn't exist, continue checking } } - + // If at least one file matches, consider it a match if (matches === 0 && sampleFiles.length > 0) { continue; // Try next candidate @@ -405,7 +453,9 @@ export function createFsRoutes(_events: EventEmitter): Router { const stats = await fs.stat(targetPath); if (!stats.isDirectory()) { - res.status(400).json({ success: false, error: "Path is not a directory" }); + res + .status(400) + .json({ success: false, error: "Path is not a directory" }); return; } @@ -438,7 +488,8 @@ export function createFsRoutes(_events: EventEmitter): Router { } catch (error) { res.status(400).json({ success: false, - error: error instanceof Error ? error.message : "Failed to read directory", + error: + error instanceof Error ? error.message : "Failed to read directory", }); } } catch (error) { @@ -464,8 +515,8 @@ export function createFsRoutes(_events: EventEmitter): Router { const fullPath = path.isAbsolute(imagePath) ? imagePath : projectPath - ? path.join(projectPath, imagePath) - : imagePath; + ? path.join(projectPath, imagePath) + : imagePath; // Check if file exists try { @@ -490,7 +541,10 @@ export function createFsRoutes(_events: EventEmitter): Router { ".bmp": "image/bmp", }; - res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream"); + res.setHeader( + "Content-Type", + mimeTypes[ext] || "application/octet-stream" + ); res.setHeader("Cache-Control", "public, max-age=3600"); res.send(buffer); } catch (error) { @@ -546,38 +600,42 @@ export function createFsRoutes(_events: EventEmitter): Router { }); // 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"); - + router.post( + "/delete-board-background", + async (req: Request, res: Response) => { 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 - } + const { projectPath } = req.body as { projectPath: string }; - res.json({ success: true }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + 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 @@ -614,7 +672,10 @@ export function createFsRoutes(_events: EventEmitter): Router { const normalizedHome = path.resolve(homeDir); // Allow browsing within home directory - if (resolved === normalizedHome || resolved.startsWith(normalizedHome + path.sep)) { + if ( + resolved === normalizedHome || + resolved.startsWith(normalizedHome + path.sep) + ) { return true; } @@ -646,7 +707,8 @@ export function createFsRoutes(_events: EventEmitter): Router { if (!isSafePath(targetPath)) { res.status(403).json({ success: false, - error: "Access denied: browsing is restricted to your home directory and allowed project paths", + error: + "Access denied: browsing is restricted to your home directory and allowed project paths", }); return; } @@ -655,7 +717,9 @@ export function createFsRoutes(_events: EventEmitter): Router { const stats = await fs.stat(targetPath); if (!stats.isDirectory()) { - res.status(400).json({ success: false, error: "Path is not a directory" }); + res + .status(400) + .json({ success: false, error: "Path is not a directory" }); return; } @@ -688,7 +752,8 @@ export function createFsRoutes(_events: EventEmitter): Router { } catch (error) { res.status(400).json({ success: false, - error: error instanceof Error ? error.message : "Failed to read directory", + error: + error instanceof Error ? error.message : "Failed to read directory", }); } } catch (error) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 69d95850..ba5b6351 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -32,7 +32,15 @@ interface Feature { priority?: number; spec?: string; model?: string; // Model to use for this feature - imagePaths?: Array; + imagePaths?: Array< + | string + | { + path: string; + filename?: string; + mimeType?: string; + [key: string]: unknown; + } + >; } interface RunningFeature { @@ -78,7 +86,7 @@ export class AutoModeService { projectPath, }; - this.emitAutoModeEvent("auto_mode_complete", { + this.emitAutoModeEvent("auto_mode_started", { message: `Auto mode started with max ${maxConcurrency} concurrent features`, projectPath, }); @@ -111,8 +119,9 @@ export class AutoModeService { ); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent("auto_mode_complete", { + this.emitAutoModeEvent("auto_mode_idle", { message: "No pending features - auto mode idle", + projectPath: this.config!.projectPath, }); await this.sleep(10000); continue; @@ -143,8 +152,9 @@ export class AutoModeService { } this.autoLoopRunning = false; - this.emitAutoModeEvent("auto_mode_complete", { + this.emitAutoModeEvent("auto_mode_stopped", { message: "Auto mode stopped", + projectPath: this.config?.projectPath, }); } @@ -230,10 +240,19 @@ export class AutoModeService { // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); - console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`); + console.log( + `[AutoMode] Executing feature ${featureId} with model: ${model}` + ); // Run the agent with the feature's model and images - await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); + await this.runAgent( + workDir, + featureId, + prompt, + abortController, + imagePaths, + model + ); // Mark as waiting_approval for user review await this.updateFeatureStatus( @@ -422,7 +441,9 @@ Address the follow-up instructions above. Review the previous work and make the try { // Get model from feature (already loaded above) const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); - console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); + console.log( + `[AutoMode] Follow-up for feature ${featureId} using model: ${model}` + ); // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -458,9 +479,11 @@ Address the follow-up instructions above. Review the previous work and make the filename ); copiedImagePaths.push(relativePath); - } catch (error) { - console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error); + console.error( + `[AutoMode] Failed to copy follow-up image ${imagePath}:`, + error + ); } } } @@ -506,7 +529,14 @@ Address the follow-up instructions above. Review the previous work and make the } // Use fullPrompt (already built above) with model and all images - await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model); + await this.runAgent( + workDir, + featureId, + fullPrompt, + abortController, + allImagePaths.length > 0 ? allImagePaths : imagePaths, + model + ); // Mark as waiting_approval for user review await this.updateFeatureStatus( @@ -717,7 +747,10 @@ Format your response as a structured markdown document.`; try { // Use default Claude model for analysis (can be overridden in the future) - const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); + const analysisModel = resolveModelString( + undefined, + DEFAULT_MODELS.claude + ); const provider = ProviderFactory.getProviderForModel(analysisModel); const options: ExecuteOptions = { @@ -917,7 +950,11 @@ Format your response as a structured markdown document.`; try { const data = await fs.readFile(featurePath, "utf-8"); const feature = JSON.parse(data); - if (feature.status === "pending" || feature.status === "ready") { + if ( + feature.status === "pending" || + feature.status === "ready" || + feature.status === "backlog" + ) { features.push(feature); } } catch { @@ -998,9 +1035,15 @@ ${feature.spec} 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}`; + 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"); @@ -1038,7 +1081,9 @@ When done, summarize what you implemented and any notes for the developer.`; model?: string ): Promise { const finalModel = resolveModelString(model, DEFAULT_MODELS.claude); - console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`); + console.log( + `[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}` + ); // Get provider for this model const provider = ProviderFactory.getProviderForModel(finalModel); @@ -1060,14 +1105,7 @@ When done, summarize what you implemented and any notes for the developer.`; model: finalModel, maxTurns: 50, cwd: workDir, - allowedTools: [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - ], + allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], abortController, }; @@ -1089,12 +1127,15 @@ When done, summarize what you implemented and any notes for the developer.`; responseText = block.text || ""; // Check for authentication errors in the response - if (block.text && (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 GOOGLE_API_KEY, or run 'claude login' to re-authenticate." + "Please check your ANTHROPIC_API_KEY or GOOGLE_API_KEY, or run 'claude login' to re-authenticate." ); } From 7b3be213e4fdee0482299e88744687b81dc20192 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 14 Dec 2025 00:51:35 -0500 Subject: [PATCH 6/6] refactor: improve auto mode service stop logic and event emission - Updated the stopAutoLoop method to emit the "auto_mode_stopped" event immediately when the loop is explicitly stopped, enhancing event handling. - Improved code readability by restructuring feature retrieval calls in integration tests for better clarity. --- apps/server/src/services/auto-mode-service.ts | 13 ++++++--- .../auto-mode-service.integration.test.ts | 29 +++++++++++-------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ba5b6351..527a0e8d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -152,22 +152,27 @@ export class AutoModeService { } this.autoLoopRunning = false; - this.emitAutoModeEvent("auto_mode_stopped", { - message: "Auto mode stopped", - projectPath: this.config?.projectPath, - }); } /** * Stop the auto mode loop */ async stopAutoLoop(): Promise { + const wasRunning = this.autoLoopRunning; this.autoLoopRunning = false; if (this.autoLoopAbortController) { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } + // Emit stop event immediately when user explicitly stops + if (wasRunning) { + this.emitAutoModeEvent("auto_mode_stopped", { + message: "Auto mode stopped", + projectPath: this.config?.projectPath, + }); + } + return this.runningFeatures.size; } diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index 932417b3..5db48152 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -119,7 +119,10 @@ describe("auto-mode-service.ts (integration)", () => { ); // Verify feature status was updated to backlog (error status) - const feature = await featureLoader.get(testRepo.path, "test-feature-error"); + const feature = await featureLoader.get( + testRepo.path, + "test-feature-error" + ); expect(feature?.status).toBe("backlog"); }, 30000); @@ -154,7 +157,10 @@ describe("auto-mode-service.ts (integration)", () => { ); // Feature should be updated successfully - const feature = await featureLoader.get(testRepo.path, "test-no-worktree"); + const feature = await featureLoader.get( + testRepo.path, + "test-no-worktree" + ); expect(feature?.status).toBe("waiting_approval"); }, 30000); }); @@ -313,7 +319,9 @@ describe("auto-mode-service.ts (integration)", () => { ); // Should have used claude-sonnet-4-20250514 - expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514"); + expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith( + "claude-sonnet-4-20250514" + ); }, 30000); }); @@ -447,9 +455,11 @@ describe("auto-mode-service.ts (integration)", () => { await service.stopAutoLoop(); await startPromise.catch(() => {}); - // Check stop event was emitted (auto_mode_complete event) - const stopEvent = mockEvents.emit.mock.calls.find((call) => - call[1]?.type === "auto_mode_complete" || call[1]?.message?.includes("stopped") + // Check stop event was emitted (emitted immediately by stopAutoLoop) + const stopEvent = mockEvents.emit.mock.calls.find( + (call) => + call[1]?.type === "auto_mode_stopped" || + call[1]?.message?.includes("Auto mode stopped") ); expect(stopEvent).toBeTruthy(); }, 10000); @@ -476,12 +486,7 @@ describe("auto-mode-service.ts (integration)", () => { ); // Should not throw - await service.executeFeature( - testRepo.path, - "error-feature", - true, - false - ); + await service.executeFeature(testRepo.path, "error-feature", true, false); // Feature should be marked as backlog (error status) const feature = await featureLoader.get(testRepo.path, "error-feature");