chore: update project management and API integration

- Added new scripts for server development and full application startup in package.json.
- Enhanced project management by checking for existing projects to avoid duplicates.
- Improved API integration with better error handling and connection checks in the Electron API.
- Updated UI components to reflect changes in project and session management.
- Refactored authentication status display to include more detailed information on methods used.
This commit is contained in:
SuperComboGamer
2025-12-12 00:23:43 -05:00
parent 02a1af3314
commit 4b9bd2641f
44 changed files with 8287 additions and 703 deletions

View File

@@ -0,0 +1,562 @@
/**
* Agent Service - Runs Claude agents via the Claude Agent SDK
* Manages conversation sessions and streams responses via WebSocket
*/
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
images?: Array<{
data: string;
mimeType: string;
filename: string;
}>;
timestamp: string;
isError?: boolean;
}
interface Session {
messages: Message[];
isRunning: boolean;
abortController: AbortController | null;
workingDirectory: string;
}
interface SessionMetadata {
id: string;
name: string;
projectPath?: string;
workingDirectory: string;
createdAt: string;
updatedAt: string;
archived?: boolean;
tags?: string[];
}
export class AgentService {
private sessions = new Map<string, Session>();
private stateDir: string;
private metadataFile: string;
private events: EventEmitter;
constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, "agent-sessions");
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
this.events = events;
}
async initialize(): Promise<void> {
await fs.mkdir(this.stateDir, { recursive: true });
}
/**
* Start or resume a conversation
*/
async startConversation({
sessionId,
workingDirectory,
}: {
sessionId: string;
workingDirectory?: string;
}) {
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,
}: {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
}) {
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 and convert to base64
const images: Message["images"] = [];
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<string, string> = {
".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),
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
// Add user message
const userMessage: Message = {
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();
// Emit user message event
this.emitAgentEvent(sessionId, {
type: "message",
message: userMessage,
});
await this.saveSession(sessionId, session.messages);
try {
const options: Options = {
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
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
message;
if (imagePaths && imagePaths.length > 0) {
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
if (message && message.trim()) {
contentBlocks.push({ type: "text", text: message });
}
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<string, string> = {
".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;
}
}
// 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;
const stream = query({ prompt: promptPayload, options });
let currentAssistantMessage: Message | null = null;
let responseText = "";
const toolUses: Array<{ name: string; input: unknown }> = [];
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;
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: this.generateId(),
role: "assistant",
content: responseText,
timestamp: new Date().toISOString(),
};
session.messages.push(currentAssistantMessage);
} else {
currentAssistantMessage.content = responseText;
}
this.emitAgentEvent(sessionId, {
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);
this.emitAgentEvent(sessionId, {
type: "tool_use",
tool: toolUse,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success" && msg.result) {
if (currentAssistantMessage) {
currentAssistantMessage.content = msg.result;
responseText = msg.result;
}
}
this.emitAgentEvent(sessionId, {
type: "complete",
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
});
}
}
await this.saveSession(sessionId, session.messages);
session.isRunning = false;
session.abortController = null;
return {
success: true,
message: currentAssistantMessage,
};
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
session.isRunning = false;
session.abortController = null;
return { success: false, aborted: true };
}
console.error("[AgentService] Error:", error);
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: "assistant",
content: `Error: ${(error as Error).message}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: "error",
error: (error as Error).message,
message: errorMessage,
});
throw error;
}
}
/**
* Get conversation history
*/
getHistory(sessionId: string) {
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: string) {
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: string) {
const session = this.sessions.get(sessionId);
if (session) {
session.messages = [];
session.isRunning = false;
await this.saveSession(sessionId, []);
}
return { success: true };
}
// Session management
async loadSession(sessionId: string): Promise<Message[]> {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
return JSON.parse(data);
} catch {
return [];
}
}
async saveSession(sessionId: string, messages: Message[]): Promise<void> {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
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);
}
}
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = await fs.readFile(this.metadataFile, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8");
}
async updateSessionTimestamp(sessionId: string): Promise<void> {
const metadata = await this.loadMetadata();
if (metadata[sessionId]) {
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
}
}
async listSessions(includeArchived = false): Promise<SessionMetadata[]> {
const metadata = await this.loadMetadata();
let sessions = Object.values(metadata);
if (!includeArchived) {
sessions = sessions.filter((s) => !s.archived);
}
return sessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
async createSession(
name: string,
projectPath?: string,
workingDirectory?: string
): Promise<SessionMetadata> {
const sessionId = this.generateId();
const metadata = await this.loadMetadata();
const session: SessionMetadata = {
id: sessionId,
name,
projectPath,
workingDirectory: workingDirectory || projectPath || process.cwd(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
metadata[sessionId] = session;
await this.saveMetadata(metadata);
return session;
}
async updateSession(
sessionId: string,
updates: Partial<SessionMetadata>
): Promise<SessionMetadata | null> {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) return null;
metadata[sessionId] = {
...metadata[sessionId],
...updates,
updatedAt: new Date().toISOString(),
};
await this.saveMetadata(metadata);
return metadata[sessionId];
}
async archiveSession(sessionId: string): Promise<boolean> {
const result = await this.updateSession(sessionId, { archived: true });
return result !== null;
}
async unarchiveSession(sessionId: string): Promise<boolean> {
const result = await this.updateSession(sessionId, { archived: false });
return result !== null;
}
async deleteSession(sessionId: string): Promise<boolean> {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) return false;
delete metadata[sessionId];
await this.saveMetadata(metadata);
// Delete session file
try {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
await fs.unlink(sessionFile);
} catch {
// File may not exist
}
// Clear from memory
this.sessions.delete(sessionId);
return true;
}
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit("agent:stream", { sessionId, ...data });
}
private getSystemPrompt(): string {
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.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
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`;
}
private generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}

View File

@@ -0,0 +1,815 @@
/**
* Auto Mode Service - Autonomous feature implementation using Claude Agent SDK
*
* Manages:
* - Worktree creation for isolated development
* - Feature execution with Claude
* - Concurrent execution with max concurrency limits
* - Progress streaming via events
* - Verification and merge workflows
*/
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter, EventType } from "../lib/events.js";
const execAsync = promisify(exec);
interface Feature {
id: string;
title: string;
description: string;
status: string;
priority?: number;
spec?: string;
}
interface RunningFeature {
featureId: string;
projectPath: string;
worktreePath: string | null;
branchName: string | null;
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
}
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Start the auto mode loop - continuously picks and executes pending features
*/
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
if (this.autoLoopRunning) {
throw new Error("Auto mode is already running");
}
this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController();
this.config = {
maxConcurrency,
useWorktrees: true,
projectPath,
};
this.emitAutoModeEvent("auto_mode_complete", {
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath,
});
// Run the loop in the background
this.runAutoLoop().catch((error) => {
console.error("[AutoMode] Loop error:", error);
this.emitAutoModeEvent("auto_mode_error", {
error: error.message,
});
});
}
private async runAutoLoop(): Promise<void> {
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
try {
// Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
await this.sleep(5000);
continue;
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", {
message: "No pending features - auto mode idle",
});
await this.sleep(10000);
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
if (nextFeature) {
// Start feature execution in background
this.executeFeature(
this.config!.projectPath,
nextFeature.id,
this.config!.useWorktrees,
true
).catch((error) => {
console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error);
});
}
await this.sleep(2000);
} catch (error) {
console.error("[AutoMode] Loop iteration error:", error);
await this.sleep(5000);
}
}
this.autoLoopRunning = false;
this.emitAutoModeEvent("auto_mode_complete", {
message: "Auto mode stopped",
});
}
/**
* Stop the auto mode loop
*/
async stopAutoLoop(): Promise<number> {
this.autoLoopRunning = false;
if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort();
this.autoLoopAbortController = null;
}
return this.runningFeatures.size;
}
/**
* Execute a single feature
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = true,
isAutoMode = false
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Setup worktree if enabled
if (useWorktrees) {
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
}
const workDir = worktreePath || projectPath;
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Emit feature start event
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
});
try {
// Load feature details
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Update feature status to in-progress
await this.updateFeatureStatus(projectPath, featureId, "in-progress");
// Build the prompt
const prompt = this.buildFeaturePrompt(feature);
// Run the agent
await this.runAgent(workDir, featureId, prompt, abortController);
// Mark as completed
await this.updateFeatureStatus(projectPath, featureId, "completed");
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
projectPath,
});
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
message: "Feature stopped by user",
projectPath,
});
} else {
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "failed");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
* Stop a specific feature
*/
async stopFeature(featureId: string): Promise<boolean> {
const running = this.runningFeatures.get(featureId);
if (!running) {
return false;
}
running.abortController.abort();
return true;
}
/**
* Resume a feature (continues from saved context)
*/
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = true
): Promise<void> {
// Check if context exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
let hasContext = false;
try {
await fs.access(contextPath);
hasContext = true;
} catch {
// No context
}
if (hasContext) {
// Load previous context and continue
const context = await fs.readFile(contextPath, "utf-8");
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
}
// No context, start fresh
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
/**
* Follow up on a feature with additional instructions
*/
async followUpFeature(
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree, use project path
}
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath: workDir !== projectPath ? worktreePath : null,
branchName: `feature/${featureId}`,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
});
try {
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: "Follow-up completed successfully",
projectPath,
});
} catch (error) {
if (!(error instanceof AbortError)) {
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
* Verify a feature's implementation
*/
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
}
// Run verification - check if tests pass, build works, etc.
const verificationChecks = [
{ cmd: "npm run lint", name: "Lint" },
{ cmd: "npm run typecheck", name: "Type check" },
{ cmd: "npm test", name: "Tests" },
{ cmd: "npm run build", name: "Build" },
];
let allPassed = true;
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
for (const check of verificationChecks) {
try {
const { stdout, stderr } = await execAsync(check.cmd, {
cwd: workDir,
timeout: 120000,
});
results.push({ check: check.name, passed: true, output: stdout || stderr });
} catch (error) {
allPassed = false;
results.push({
check: check.name,
passed: false,
output: (error as Error).message,
});
break; // Stop on first failure
}
}
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: allPassed,
message: allPassed
? "All verification checks passed"
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
});
return allPassed;
}
/**
* Commit feature changes
*/
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
}
try {
// Check for changes
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
if (!status.trim()) {
return null; // No changes
}
// Load feature for commit message
const feature = await this.loadFeature(projectPath, featureId);
const commitMessage = feature
? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode`
: `feat: Feature ${featureId}`;
// Stage and commit
await execAsync("git add -A", { cwd: workDir });
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
cwd: workDir,
});
// Get commit hash
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
});
return hash.trim();
} catch (error) {
console.error(`[AutoMode] Commit failed for ${featureId}:`, error);
return null;
}
}
/**
* Check if context exists for a feature
*/
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
try {
await fs.access(contextPath);
return true;
} catch {
return false;
}
}
/**
* Analyze project to gather context
*/
async analyzeProject(projectPath: string): Promise<void> {
const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`;
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId: analysisFeatureId,
projectPath,
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
});
const prompt = `Analyze this project and provide a summary of:
1. Project structure and architecture
2. Main technologies and frameworks used
3. Key components and their responsibilities
4. Build and test commands
5. Any existing conventions or patterns
Format your response as a structured markdown document.`;
try {
const options: Options = {
model: "claude-sonnet-4-20250514",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let analysisResult = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
analysisResult = block.text;
this.emitAutoModeEvent("auto_mode_progress", {
featureId: analysisFeatureId,
content: block.text,
projectPath,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
analysisResult = msg.result || analysisResult;
}
}
// Save analysis
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId: analysisFeatureId,
passes: true,
message: "Project analysis completed",
projectPath,
});
} catch (error) {
this.emitAutoModeEvent("auto_mode_error", {
featureId: analysisFeatureId,
error: (error as Error).message,
projectPath,
});
}
}
/**
* Get current status
*/
getStatus(): {
isRunning: boolean;
autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
autoLoopRunning: this.autoLoopRunning,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
}
// Private helpers
private async setupWorktree(
projectPath: string,
featureId: string,
branchName: string
): Promise<string> {
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
const worktreePath = path.join(worktreesDir, featureId);
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree already exists
try {
await fs.access(worktreePath);
return worktreePath;
} catch {
// Create new worktree
}
// Create branch if it doesn't exist
try {
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
} catch {
// Branch may already exist
}
// Create worktree
try {
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
cwd: projectPath,
});
} catch (error) {
// Worktree creation failed, fall back to direct execution
console.error(`[AutoMode] Worktree creation failed:`, error);
return projectPath;
}
return worktreePath;
}
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
private async updateFeatureStatus(
projectPath: string,
featureId: string,
status: string
): Promise<void> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
feature.status = status;
feature.updatedAt = new Date().toISOString();
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch {
// Feature file may not exist
}
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
const featuresDir = path.join(projectPath, ".automaker", "features");
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const features: Feature[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
if (feature.status === "pending" || feature.status === "ready") {
features.push(feature);
}
} catch {
// Skip invalid features
}
}
}
// Sort by priority
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
} catch {
return [];
}
}
private buildFeaturePrompt(feature: Feature): string {
let prompt = `## Feature Implementation Task
**Feature ID:** ${feature.id}
**Title:** ${feature.title}
**Description:** ${feature.description}
`;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
prompt += `
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
When done, summarize what you implemented and any notes for the developer.`;
return prompt;
}
private async runAgent(
workDir: string,
featureId: string,
prompt: string,
abortController: AbortController,
imagePaths?: string[]
): Promise<void> {
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 50,
cwd: workDir,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"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 });
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) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: block.text,
});
} else if (block.type === "tool_use") {
this.emitAutoModeEvent("auto_mode_tool", {
featureId,
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Save agent output
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch {
// May fail if directory doesn't exist
}
}
private async executeFeatureWithContext(
projectPath: string,
featureId: string,
context: string,
useWorktrees: boolean
): Promise<void> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const prompt = `## Continuing Feature Implementation
${this.buildFeaturePrompt(feature)}
## Previous Context
The following is the output from a previous implementation attempt. Continue from where you left off:
${context}
## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
/**
* Emit an auto-mode event wrapped in the correct format for the client.
* All auto-mode events are sent as type "auto-mode:event" with the actual
* event type and data in the payload.
*/
private emitAutoModeEvent(
eventType: string,
data: Record<string, unknown>
): void {
// Wrap the event in auto-mode:event format expected by the client
this.events.emit("auto-mode:event", {
type: eventType,
...data,
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,263 @@
/**
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
import path from "path";
import fs from "fs/promises";
export interface Feature {
id: string;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown;
}
export class FeatureLoader {
/**
* Get the features directory path
*/
getFeaturesDir(projectPath: string): string {
return path.join(projectPath, ".automaker", "features");
}
/**
* Get the path to a specific feature folder
*/
getFeatureDir(projectPath: string, featureId: string): string {
return path.join(this.getFeaturesDir(projectPath), featureId);
}
/**
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
}
/**
* Generate a new feature ID
*/
generateFeatureId(): string {
return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Get all features for a project
*/
async getAll(projectPath: string): Promise<Feature[]> {
try {
const featuresDir = this.getFeaturesDir(projectPath);
// Check if features directory exists
try {
await fs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
const features: Feature[] = [];
for (const dir of featureDirs) {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const feature = JSON.parse(content);
if (!feature.id) {
console.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
}
features.push(feature);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
} else if (error instanceof SyntaxError) {
console.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
console.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
}
}
}
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
return aTime - bTime;
});
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to get all features:", error);
return [];
}
}
/**
* Get a single feature by ID
*/
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
throw error;
}
}
/**
* Create a new feature
*/
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure features directory exists
const featuresDir = this.getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || "Uncategorized",
description: featureData.description || "",
...featureData,
id: featureId,
};
// Write feature.json
await fs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), "utf-8");
console.log(`[FeatureLoader] Created feature ${featureId}`);
return feature;
}
/**
* Update a feature (partial updates supported)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Merge updates
const updatedFeature: Feature = { ...feature, ...updates };
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Updated feature ${featureId}`);
return updatedFeature;
}
/**
* Delete a feature
*/
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
return false;
}
}
/**
* Get agent output for a feature
*/
async getAgentOutput(
projectPath: string,
featureId: string
): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
throw error;
}
}
/**
* Save agent output for a feature
*/
async saveAgentOutput(
projectPath: string,
featureId: string,
content: string
): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
}
/**
* Delete agent output for a feature
*/
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
}