const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk"); const path = require("path"); const fs = require("fs/promises"); /** * Agent Service - Runs Claude agents in the Electron main process * This service survives Next.js restarts and maintains conversation state */ class AgentService { constructor() { this.sessions = new Map(); // sessionId -> { messages, isRunning, abortController } this.stateDir = null; // Will be set when app is ready } /** * Initialize the service with app data directory */ async initialize(appDataPath) { this.stateDir = path.join(appDataPath, "agent-sessions"); this.metadataFile = path.join(appDataPath, "sessions-metadata.json"); await fs.mkdir(this.stateDir, { recursive: true }); console.log("[AgentService] Initialized with state dir:", this.stateDir); } /** * Start or resume a conversation */ async startConversation({ sessionId, workingDirectory }) { console.log("[AgentService] Starting conversation:", sessionId); // Initialize session if it doesn't exist if (!this.sessions.has(sessionId)) { const messages = await this.loadSession(sessionId); this.sessions.set(sessionId, { messages, isRunning: false, abortController: null, workingDirectory: workingDirectory || process.cwd(), }); } const session = this.sessions.get(sessionId); return { success: true, messages: session.messages, sessionId, }; } /** * Send a message to the agent and stream responses */ async sendMessage({ sessionId, message, workingDirectory, imagePaths, sendToRenderer, }) { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); } if (session.isRunning) { throw new Error("Agent is already processing a message"); } // Read images from temp files and convert to base64 for storage const images = []; if (imagePaths && imagePaths.length > 0) { const fs = require("fs/promises"); const path = require("path"); for (const imagePath of imagePaths) { try { const imageBuffer = await fs.readFile(imagePath); const base64Data = imageBuffer.toString("base64"); // Determine media type from file extension const ext = path.extname(imagePath).toLowerCase(); const mimeTypeMap = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", }; const mediaType = mimeTypeMap[ext] || "image/png"; images.push({ data: base64Data, mimeType: mediaType, filename: path.basename(imagePath), }); console.log( `[AgentService] Loaded image from ${imagePath} for storage` ); } catch (error) { console.error( `[AgentService] Failed to load image from ${imagePath}:`, error ); } } } // Add user message to conversation with base64 images const userMessage = { id: this.generateId(), role: "user", content: message, images: images.length > 0 ? images : undefined, timestamp: new Date().toISOString(), }; session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); // Send initial user message to renderer sendToRenderer({ type: "message", message: userMessage, }); // Save state with base64 images await this.saveSession(sessionId, session.messages); try { // Configure Claude Agent SDK options const options = { // model: "claude-sonnet-4-20250514", model: "claude-opus-4-5-20251101", systemPrompt: this.getSystemPrompt(), maxTurns: 20, cwd: workingDirectory || session.workingDirectory, allowedTools: [ "Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebSearch", "WebFetch", ], permissionMode: "acceptEdits", sandbox: { enabled: true, autoAllowBashIfSandboxed: true, }, abortController: session.abortController, }; // Build prompt content with text and images let promptContent = message; // If there are images, create a content array if (imagePaths && imagePaths.length > 0) { const contentBlocks = []; // Add text block if (message && message.trim()) { contentBlocks.push({ type: "text", text: message, }); } // Add image blocks const fs = require("fs"); for (const imagePath of imagePaths) { try { const imageBuffer = fs.readFileSync(imagePath); const base64Data = imageBuffer.toString("base64"); const ext = path.extname(imagePath).toLowerCase(); const mimeTypeMap = { ".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 ); } } // Use content blocks if we have images if ( contentBlocks.length > 1 || (contentBlocks.length === 1 && contentBlocks[0].type === "image") ) { promptContent = contentBlocks; } } // Build payload for the SDK const promptPayload = Array.isArray(promptContent) ? (async function* () { yield { type: "user", session_id: "", message: { role: "user", content: promptContent, }, parent_tool_use_id: null, }; })() : promptContent; // Send the query via the SDK (conversation state handled by the SDK) const stream = query({ prompt: promptPayload, options }); let currentAssistantMessage = null; let responseText = ""; const toolUses = []; // Stream responses from the SDK for await (const msg of stream) { if (msg.type === "assistant") { if (msg.message.content) { for (const block of msg.message.content) { if (block.type === "text") { responseText += block.text; // Create or update assistant message if (!currentAssistantMessage) { currentAssistantMessage = { id: this.generateId(), role: "assistant", content: responseText, timestamp: new Date().toISOString(), }; session.messages.push(currentAssistantMessage); } else { currentAssistantMessage.content = responseText; } // Stream to renderer sendToRenderer({ type: "stream", messageId: currentAssistantMessage.id, content: responseText, isComplete: false, }); } else if (block.type === "tool_use") { const toolUse = { name: block.name, input: block.input, }; toolUses.push(toolUse); // Send tool use notification sendToRenderer({ type: "tool_use", tool: toolUse, }); } } } } else if (msg.type === "result") { if (msg.subtype === "success" && msg.result) { // Use the final result if (currentAssistantMessage) { currentAssistantMessage.content = msg.result; responseText = msg.result; } } // Send completion sendToRenderer({ type: "complete", messageId: currentAssistantMessage?.id, content: responseText, toolUses, }); } } // Save final state await this.saveSession(sessionId, session.messages); session.isRunning = false; session.abortController = null; return { success: true, message: currentAssistantMessage, }; } catch (error) { if (error instanceof AbortError || error?.name === "AbortError") { console.log("[AgentService] Query aborted"); session.isRunning = false; session.abortController = null; return { success: false, aborted: true }; } console.error("[AgentService] Error:", error); session.isRunning = false; session.abortController = null; // Add error message const errorMessage = { id: this.generateId(), role: "assistant", content: `Error: ${error.message}`, timestamp: new Date().toISOString(), isError: true, }; session.messages.push(errorMessage); await this.saveSession(sessionId, session.messages); sendToRenderer({ type: "error", error: error.message, message: errorMessage, }); throw error; } } /** * Get conversation history */ getHistory(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return { success: false, error: "Session not found" }; } return { success: true, messages: session.messages, isRunning: session.isRunning, }; } /** * Stop current agent execution */ async stopExecution(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return { success: false, error: "Session not found" }; } if (session.abortController) { session.abortController.abort(); session.isRunning = false; session.abortController = null; } return { success: true }; } /** * Clear conversation history */ async clearSession(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.messages = []; session.isRunning = false; await this.saveSession(sessionId, []); } return { success: true }; } /** * Load session from disk */ async loadSession(sessionId) { if (!this.stateDir) return []; const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { const data = await fs.readFile(sessionFile, "utf-8"); const parsed = JSON.parse(data); console.log( `[AgentService] Loaded ${parsed.length} messages for ${sessionId}` ); return parsed; } catch (error) { // Session doesn't exist yet return []; } } /** * Save session to disk */ async saveSession(sessionId, messages) { if (!this.stateDir) return; const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { await fs.writeFile( sessionFile, JSON.stringify(messages, null, 2), "utf-8" ); console.log( `[AgentService] Saved ${messages.length} messages for ${sessionId}` ); // Update timestamp await this.updateSessionTimestamp(sessionId); } catch (error) { console.error("[AgentService] Failed to save session:", error); } } /** * Get system prompt */ getSystemPrompt() { return `You are an AI assistant helping users build software. You are part of the Automaker application, which is designed to help developers plan, design, and implement software projects autonomously. Your role is to: - Help users define their project requirements and specifications - Ask clarifying questions to better understand their needs - Suggest technical approaches and architectures - Guide them through the development process - Be conversational and helpful - Write, edit, and modify code files as requested - Execute commands and tests - Search and analyze the codebase When discussing projects, help users think through: - Core functionality and features - Technical stack choices - Data models and architecture - User experience considerations - Testing strategies You have full access to the codebase and can: - Read files to understand existing code - Write new files - Edit existing files - Run bash commands - Search for code patterns - Execute tests and builds IMPORTANT: When making file changes, be aware that the Next.js development server may restart. This is normal and expected. Your conversation state is preserved across these restarts.`; } /** * Generate unique ID */ generateId() { return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } // ============================================================================ // Session Management // ============================================================================ /** * Load all session metadata */ async loadMetadata() { if (!this.metadataFile) return {}; try { const data = await fs.readFile(this.metadataFile, "utf-8"); return JSON.parse(data); } catch (error) { return {}; } } /** * Save session metadata */ async saveMetadata(metadata) { if (!this.metadataFile) return; try { await fs.writeFile( this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8" ); } catch (error) { console.error("[AgentService] Failed to save metadata:", error); } } /** * List all sessions */ async listSessions({ includeArchived = false } = {}) { const metadata = await this.loadMetadata(); const sessions = []; for (const [sessionId, meta] of Object.entries(metadata)) { if (!includeArchived && meta.isArchived) continue; const messages = await this.loadSession(sessionId); const lastMessage = messages[messages.length - 1]; sessions.push({ id: sessionId, name: meta.name || sessionId, projectPath: meta.projectPath || "", createdAt: meta.createdAt, updatedAt: meta.updatedAt, messageCount: messages.length, isArchived: meta.isArchived || false, tags: meta.tags || [], preview: lastMessage?.content.substring(0, 100) || "", }); } // Sort by most recently updated sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); return sessions; } /** * Create a new session */ async createSession({ name, projectPath, workingDirectory }) { const sessionId = `session_${Date.now()}_${Math.random() .toString(36) .substring(2, 11)}`; const metadata = await this.loadMetadata(); metadata[sessionId] = { name, projectPath, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), isArchived: false, tags: [], }; await this.saveMetadata(metadata); this.sessions.set(sessionId, { messages: [], isRunning: false, abortController: null, workingDirectory: workingDirectory || projectPath, }); await this.saveSession(sessionId, []); return { success: true, sessionId, session: metadata[sessionId], }; } /** * Update session metadata */ async updateSession({ sessionId, name, tags }) { const metadata = await this.loadMetadata(); if (!metadata[sessionId]) { return { success: false, error: "Session not found" }; } if (name !== undefined) metadata[sessionId].name = name; if (tags !== undefined) metadata[sessionId].tags = tags; metadata[sessionId].updatedAt = new Date().toISOString(); await this.saveMetadata(metadata); return { success: true }; } /** * Archive a session */ async archiveSession(sessionId) { const metadata = await this.loadMetadata(); if (!metadata[sessionId]) { return { success: false, error: "Session not found" }; } metadata[sessionId].isArchived = true; metadata[sessionId].updatedAt = new Date().toISOString(); await this.saveMetadata(metadata); return { success: true }; } /** * Unarchive a session */ async unarchiveSession(sessionId) { const metadata = await this.loadMetadata(); if (!metadata[sessionId]) { return { success: false, error: "Session not found" }; } metadata[sessionId].isArchived = false; metadata[sessionId].updatedAt = new Date().toISOString(); await this.saveMetadata(metadata); return { success: true }; } /** * Delete a session permanently */ async deleteSession(sessionId) { const metadata = await this.loadMetadata(); if (!metadata[sessionId]) { return { success: false, error: "Session not found" }; } // Remove from metadata delete metadata[sessionId]; await this.saveMetadata(metadata); // Remove from memory this.sessions.delete(sessionId); // Delete session file const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { await fs.unlink(sessionFile); } catch (error) { console.warn("[AgentService] Failed to delete session file:", error); } return { success: true }; } /** * Update session metadata when messages change */ async updateSessionTimestamp(sessionId) { const metadata = await this.loadMetadata(); if (metadata[sessionId]) { metadata[sessionId].updatedAt = new Date().toISOString(); await this.saveMetadata(metadata); } } } // Export singleton instance module.exports = new AgentService();