Merge branch 'main' of github.com:webdevcody/automaker

This commit is contained in:
Cody Seibert
2025-12-14 01:01:01 -05:00
16 changed files with 905 additions and 397 deletions

View File

@@ -19,6 +19,7 @@ import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context"; import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
interface InterviewMessage { interface InterviewMessage {
id: string; id: string;
@@ -290,7 +291,8 @@ export function InterviewView() {
const handleSelectDirectory = async () => { const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({ const selectedPath = await openFileBrowser({
title: "Select Base Directory", 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) { if (selectedPath) {
@@ -306,12 +308,23 @@ export function InterviewView() {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
// Use platform-specific path separator // Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ? const pathSep =
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/'; typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const fullProjectPath = `${projectPath}${pathSep}${projectName}`; const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory // 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 // Write app_spec.txt with generated content
await api.writeFile( await api.writeFile(

View File

@@ -236,7 +236,13 @@ export function WelcomeView() {
const projectPath = `${parentDir}/${projectName}`; const projectPath = `${parentDir}/${projectName}`;
// Create project directory // 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 // Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath); const initResult = await initializeProject(projectPath);

View File

@@ -33,16 +33,21 @@ export function useAutoMode() {
); );
// Helper to look up project ID from path // Helper to look up project ID from path
const getProjectIdFromPath = useCallback((path: string): string | undefined => { const getProjectIdFromPath = useCallback(
const project = projects.find(p => p.path === path); (path: string): string | undefined => {
return project?.id; const project = projects.find((p) => p.path === path);
}, [projects]); return project?.id;
},
[projects]
);
// Get project-specific auto mode state // Get project-specific auto mode state
const projectId = currentProject?.id; const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => { const projectAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] }; if (!projectId) return { isRunning: false, runningTasks: [] };
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }; return (
autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }
);
}, [autoModeByProject, projectId]); }, [autoModeByProject, projectId]);
const isAutoModeRunning = projectAutoModeState.isRunning; const isAutoModeRunning = projectAutoModeState.isRunning;
@@ -62,10 +67,10 @@ export function useAutoMode() {
// Events include projectPath from backend - use it to look up project ID // Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event // Fall back to current projectId if not provided in event
let eventProjectId: string | undefined; let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) { if ("projectPath" in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath); eventProjectId = getProjectIdFromPath(event.projectPath);
} }
if (!eventProjectId && 'projectId' in event && event.projectId) { if (!eventProjectId && "projectId" in event && event.projectId) {
eventProjectId = event.projectId; eventProjectId = event.projectId;
} }
if (!eventProjectId) { if (!eventProjectId) {
@@ -74,7 +79,10 @@ export function useAutoMode() {
// Skip event if we couldn't determine the project // Skip event if we couldn't determine the project
if (!eventProjectId) { if (!eventProjectId) {
console.warn("[AutoMode] Could not determine project for event:", event); console.warn(
"[AutoMode] Could not determine project for event:",
event
);
return; return;
} }
@@ -111,20 +119,41 @@ export function useAutoMode() {
} }
break; break;
case "auto_mode_complete": case "auto_mode_stopped":
// All features completed for this project // Auto mode was explicitly stopped (by user or error)
setAutoModeRunning(eventProjectId, false); setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId); 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; break;
case "auto_mode_error": case "auto_mode_error":
console.error("[AutoMode Error]", event.error); console.error("[AutoMode Error]", event.error);
if (event.featureId && event.error) { if (event.featureId && event.error) {
// Check for authentication errors and provide a more helpful message // Check for authentication errors and provide a more helpful message
const isAuthError = event.errorType === "authentication" || const isAuthError =
event.error.includes("Authentication failed") || event.errorType === "authentication" ||
event.error.includes("Invalid API key"); event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key");
const errorMessage = isAuthError const errorMessage = isAuthError
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.` ? `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; if (!api?.autoMode) return;
// Find all projects that have auto mode marked as running // 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)) { for (const [projectId, state] of Object.entries(autoModeByProject)) {
if (state.isRunning) { if (state.isRunning) {
// Find the project path for this project ID // 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) { if (project) {
projectsToRestart.push({ projectId, projectPath: project.path }); projectsToRestart.push({ projectId, projectPath: project.path });
} }
@@ -216,18 +246,27 @@ export function useAutoMode() {
// Restart auto mode for each project // Restart auto mode for each project
for (const { projectId, projectPath } of projectsToRestart) { for (const { projectId, projectPath } of projectsToRestart) {
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`); console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
api.autoMode.start(projectPath, maxConcurrency).then(result => { api.autoMode
if (!result.success) { .start(projectPath, maxConcurrency)
console.error(`[AutoMode] Failed to restore auto mode for ${projectPath}:`, result.error); .then((result) => {
// Mark as not running if we couldn't restart 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); 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 // Only run once on mount - intentionally empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -246,11 +285,16 @@ export function useAutoMode() {
throw new Error("Auto mode API not available"); 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) { if (result.success) {
setAutoModeRunning(currentProject.id, true); setAutoModeRunning(currentProject.id, true);
console.log(`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`); console.log(
`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
);
} else { } else {
console.error("[AutoMode] Failed to start:", result.error); console.error("[AutoMode] Failed to start:", result.error);
throw new Error(result.error || "Failed to start auto mode"); 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 // Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be // from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event. // 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 { } else {
console.error("[AutoMode] Failed to stop:", result.error); console.error("[AutoMode] Failed to stop:", result.error);
throw new Error(result.error || "Failed to stop auto mode"); throw new Error(result.error || "Failed to stop auto mode");

View File

@@ -29,7 +29,9 @@ const REQUIRED_STRUCTURE: {
".automaker/features", ".automaker/features",
".automaker/images", ".automaker/images",
], ],
files: {}, files: {
".automaker/categories.json": "[]",
},
}; };
/** /**

View File

@@ -49,7 +49,9 @@ export interface ShortcutKey {
} }
// Helper to parse shortcut string to ShortcutKey object // 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: "" }; if (!shortcut) return { key: "" };
const parts = shortcut.split("+").map((p) => p.trim()); const parts = shortcut.split("+").map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] }; 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 // 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 ""; if (!shortcut) return "";
const parsed = parseShortcut(shortcut); const parsed = parseShortcut(shortcut);
const parts: string[] = []; const parts: string[] = [];
@@ -179,7 +184,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: "C", context: "C",
settings: "S", settings: "S",
profiles: "M", profiles: "M",
terminal: "Cmd+`", terminal: "T",
// UI // UI
toggleSidebar: "`", toggleSidebar: "`",
@@ -308,7 +313,12 @@ export interface ProjectAnalysis {
// Terminal panel layout types (recursive for splits) // Terminal panel layout types (recursive for splits)
export type TerminalPanelContent = export type TerminalPanelContent =
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number } | { 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 // Terminal tab - each tab has its own layout
export interface TerminalTab { export interface TerminalTab {
@@ -600,7 +610,11 @@ export interface AppActions {
// Terminal actions // Terminal actions
setTerminalUnlocked: (unlocked: boolean, token?: string) => void; setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
setActiveTerminalSession: (sessionId: string | null) => 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; removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void; swapTerminals: (sessionId1: string, sessionId2: string) => void;
clearTerminalState: () => void; clearTerminalState: () => void;
@@ -610,7 +624,11 @@ export interface AppActions {
setActiveTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void;
renameTerminalTab: (tabId: string, name: string) => void; renameTerminalTab: (tabId: string, name: string) => void;
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => 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
reset: () => void; reset: () => void;
@@ -1331,8 +1349,10 @@ export const useAppStore = create<AppState & AppActions>()(
resetAIProfiles: () => { resetAIProfiles: () => {
// Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults // 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 defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map((p) => p.id));
const userProfiles = get().aiProfiles.filter(p => !p.isBuiltIn && !defaultProfileIds.has(p.id)); const userProfiles = get().aiProfiles.filter(
(p) => !p.isBuiltIn && !defaultProfileIds.has(p.id)
);
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] }); set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
}, },
@@ -1528,9 +1548,17 @@ export const useAppStore = create<AppState & AppActions>()(
}); });
}, },
addTerminalToLayout: (sessionId, direction = "horizontal", targetSessionId) => { addTerminalToLayout: (
sessionId,
direction = "horizontal",
targetSessionId
) => {
const current = get().terminalState; 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 no tabs, create first tab
if (current.tabs.length === 0) { if (current.tabs.length === 0) {
@@ -1538,7 +1566,13 @@ export const useAppStore = create<AppState & AppActions>()(
set({ set({
terminalState: { terminalState: {
...current, ...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, activeTabId: newTabId,
activeSessionId: sessionId, activeSessionId: sessionId,
}, },
@@ -1547,7 +1581,9 @@ export const useAppStore = create<AppState & AppActions>()(
} }
// Add to active tab's layout // 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 (!activeTab) return;
// If targetSessionId is provided, find and split that specific terminal // If targetSessionId is provided, find and split that specific terminal
@@ -1571,7 +1607,9 @@ export const useAppStore = create<AppState & AppActions>()(
// It's a split - recurse into panels // It's a split - recurse into panels
return { return {
...node, ...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<AppState & AppActions>()(
const newSize = 100 / (node.panels.length + 1); const newSize = 100 / (node.panels.length + 1);
return { return {
...node, ...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 // Different direction, wrap in new split
@@ -1607,12 +1648,16 @@ export const useAppStore = create<AppState & AppActions>()(
if (!activeTab.layout) { if (!activeTab.layout) {
newLayout = { type: "terminal", sessionId, size: 100 }; newLayout = { type: "terminal", sessionId, size: 100 };
} else if (targetSessionId) { } else if (targetSessionId) {
newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction); newLayout = splitTargetTerminal(
activeTab.layout,
targetSessionId,
direction
);
} else { } else {
newLayout = addToRootLayout(activeTab.layout, direction); 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 t.id === current.activeTabId ? { ...t, layout: newLayout } : t
); );
@@ -1630,7 +1675,9 @@ export const useAppStore = create<AppState & AppActions>()(
if (current.tabs.length === 0) return; if (current.tabs.length === 0) return;
// Find which tab contains this session // 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) return null;
if (node.type === "terminal") return node.sessionId; if (node.type === "terminal") return node.sessionId;
for (const panel of node.panels) { for (const panel of node.panels) {
@@ -1640,7 +1687,9 @@ export const useAppStore = create<AppState & AppActions>()(
return null; return null;
}; };
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { const removeAndCollapse = (
node: TerminalPanelContent
): TerminalPanelContent | null => {
if (node.type === "terminal") { if (node.type === "terminal") {
return node.sessionId === sessionId ? null : node; return node.sessionId === sessionId ? null : node;
} }
@@ -1654,19 +1703,27 @@ export const useAppStore = create<AppState & AppActions>()(
return { ...node, panels: newPanels }; return { ...node, panels: newPanels };
}; };
let newTabs = current.tabs.map(tab => { let newTabs = current.tabs.map((tab) => {
if (!tab.layout) return tab; if (!tab.layout) return tab;
const newLayout = removeAndCollapse(tab.layout); const newLayout = removeAndCollapse(tab.layout);
return { ...tab, layout: newLayout }; return { ...tab, layout: newLayout };
}); });
// Remove empty tabs // Remove empty tabs
newTabs = newTabs.filter(tab => tab.layout !== null); newTabs = newTabs.filter((tab) => tab.layout !== null);
// Determine new active session // 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 const newActiveSessionId = newActiveTabId
? findFirstTerminal(newTabs.find(t => t.id === newActiveTabId)?.layout || null) ? findFirstTerminal(
newTabs.find((t) => t.id === newActiveTabId)?.layout || null
)
: null; : null;
set({ set({
@@ -1683,16 +1740,20 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().terminalState; const current = get().terminalState;
if (current.tabs.length === 0) return; if (current.tabs.length === 0) return;
const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { const swapInLayout = (
node: TerminalPanelContent
): TerminalPanelContent => {
if (node.type === "terminal") { if (node.type === "terminal") {
if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; if (node.sessionId === sessionId1)
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; return { ...node, sessionId: sessionId2 };
if (node.sessionId === sessionId2)
return { ...node, sessionId: sessionId1 };
return node; return node;
} }
return { ...node, panels: node.panels.map(swapInLayout) }; return { ...node, panels: node.panels.map(swapInLayout) };
}; };
const newTabs = current.tabs.map(tab => ({ const newTabs = current.tabs.map((tab) => ({
...tab, ...tab,
layout: tab.layout ? swapInLayout(tab.layout) : null, layout: tab.layout ? swapInLayout(tab.layout) : null,
})); }));
@@ -1719,7 +1780,9 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().terminalState; const current = get().terminalState;
const clampedSize = Math.max(8, Math.min(32, fontSize)); 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.type === "terminal") {
if (node.sessionId === sessionId) { if (node.sessionId === sessionId) {
return { ...node, fontSize: clampedSize }; return { ...node, fontSize: clampedSize };
@@ -1729,7 +1792,7 @@ export const useAppStore = create<AppState & AppActions>()(
return { ...node, panels: node.panels.map(updateFontSize) }; return { ...node, panels: node.panels.map(updateFontSize) };
}; };
const newTabs = current.tabs.map(tab => { const newTabs = current.tabs.map((tab) => {
if (!tab.layout) return tab; if (!tab.layout) return tab;
return { ...tab, layout: updateFontSize(tab.layout) }; return { ...tab, layout: updateFontSize(tab.layout) };
}); });
@@ -1743,7 +1806,11 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().terminalState; const current = get().terminalState;
const newTabId = `tab-${Date.now()}`; const newTabId = `tab-${Date.now()}`;
const tabNumber = current.tabs.length + 1; 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({ set({
terminalState: { terminalState: {
...current, ...current,
@@ -1756,14 +1823,14 @@ export const useAppStore = create<AppState & AppActions>()(
removeTerminalTab: (tabId) => { removeTerminalTab: (tabId) => {
const current = get().terminalState; 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 newActiveTabId = current.activeTabId;
let newActiveSessionId = current.activeSessionId; let newActiveSessionId = current.activeSessionId;
if (current.activeTabId === tabId) { if (current.activeTabId === tabId) {
newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null;
if (newActiveTabId) { if (newActiveTabId) {
const newActiveTab = newTabs.find(t => t.id === newActiveTabId); const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
const findFirst = (node: TerminalPanelContent): string | null => { const findFirst = (node: TerminalPanelContent): string | null => {
if (node.type === "terminal") return node.sessionId; if (node.type === "terminal") return node.sessionId;
for (const p of node.panels) { for (const p of node.panels) {
@@ -1772,20 +1839,27 @@ export const useAppStore = create<AppState & AppActions>()(
} }
return null; return null;
}; };
newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; newActiveSessionId = newActiveTab?.layout
? findFirst(newActiveTab.layout)
: null;
} else { } else {
newActiveSessionId = null; newActiveSessionId = null;
} }
} }
set({ set({
terminalState: { ...current, tabs: newTabs, activeTabId: newActiveTabId, activeSessionId: newActiveSessionId }, terminalState: {
...current,
tabs: newTabs,
activeTabId: newActiveTabId,
activeSessionId: newActiveSessionId,
},
}); });
}, },
setActiveTerminalTab: (tabId) => { setActiveTerminalTab: (tabId) => {
const current = get().terminalState; 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; if (!tab) return;
let newActiveSessionId = current.activeSessionId; let newActiveSessionId = current.activeSessionId;
@@ -1802,13 +1876,19 @@ export const useAppStore = create<AppState & AppActions>()(
} }
set({ set({
terminalState: { ...current, activeTabId: tabId, activeSessionId: newActiveSessionId }, terminalState: {
...current,
activeTabId: tabId,
activeSessionId: newActiveSessionId,
},
}); });
}, },
renameTerminalTab: (tabId, name) => { renameTerminalTab: (tabId, name) => {
const current = get().terminalState; 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({ set({
terminalState: { ...current, tabs: newTabs }, terminalState: { ...current, tabs: newTabs },
}); });
@@ -1818,9 +1898,13 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().terminalState; const current = get().terminalState;
let sourceTabId: string | null = null; 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") { if (node.type === "terminal") {
return node.sessionId === sessionId ? node : null; return node.sessionId === sessionId ? node : null;
} }
@@ -1844,10 +1928,12 @@ export const useAppStore = create<AppState & AppActions>()(
if (!sourceTabId || !originalTerminalNode) return; if (!sourceTabId || !originalTerminalNode) return;
if (sourceTabId === targetTabId) 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; if (!sourceTab?.layout) return;
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { const removeAndCollapse = (
node: TerminalPanelContent
): TerminalPanelContent | null => {
if (node.type === "terminal") { if (node.type === "terminal") {
return node.sessionId === sessionId ? null : node; return node.sessionId === sessionId ? null : node;
} }
@@ -1869,21 +1955,42 @@ export const useAppStore = create<AppState & AppActions>()(
if (targetTabId === "new") { if (targetTabId === "new") {
const newTabId = `tab-${Date.now()}`; const newTabId = `tab-${Date.now()}`;
const sourceWillBeRemoved = !newSourceLayout; const sourceWillBeRemoved = !newSourceLayout;
const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`; const tabName = sourceWillBeRemoved
? sourceTab.name
: `Terminal ${current.tabs.length + 1}`;
newTabs = [ newTabs = [
...current.tabs, ...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; finalTargetTabId = newTabId;
} else { } else {
const targetTab = current.tabs.find(t => t.id === targetTabId); const targetTab = current.tabs.find((t) => t.id === targetTabId);
if (!targetTab) return; 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; let newTargetLayout: TerminalPanelContent;
if (!targetTab.layout) { 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") { } else if (targetTab.layout.type === "terminal") {
newTargetLayout = { newTargetLayout = {
type: "split", type: "split",
@@ -1897,15 +2004,15 @@ export const useAppStore = create<AppState & AppActions>()(
}; };
} }
newTabs = current.tabs.map(t => newTabs = current.tabs.map((t) =>
t.id === targetTabId ? { ...t, layout: newTargetLayout } : t t.id === targetTabId ? { ...t, layout: newTargetLayout } : t
); );
} }
if (!newSourceLayout) { if (!newSourceLayout) {
newTabs = newTabs.filter(t => t.id !== sourceTabId); newTabs = newTabs.filter((t) => t.id !== sourceTabId);
} else { } else {
newTabs = newTabs.map(t => newTabs = newTabs.map((t) =>
t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t
); );
} }
@@ -1922,10 +2029,14 @@ export const useAppStore = create<AppState & AppActions>()(
addTerminalToTab: (sessionId, tabId, direction = "horizontal") => { addTerminalToTab: (sessionId, tabId, direction = "horizontal") => {
const current = get().terminalState; 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; if (!tab) return;
const terminalNode: TerminalPanelContent = { type: "terminal", sessionId, size: 50 }; const terminalNode: TerminalPanelContent = {
type: "terminal",
sessionId,
size: 50,
};
let newLayout: TerminalPanelContent; let newLayout: TerminalPanelContent;
if (!tab.layout) { if (!tab.layout) {
@@ -1941,7 +2052,10 @@ export const useAppStore = create<AppState & AppActions>()(
const newSize = 100 / (tab.layout.panels.length + 1); const newSize = 100 / (tab.layout.panels.length + 1);
newLayout = { newLayout = {
...tab.layout, ...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 { } else {
newLayout = { newLayout = {
@@ -1952,7 +2066,7 @@ export const useAppStore = create<AppState & AppActions>()(
} }
} }
const newTabs = current.tabs.map(t => const newTabs = current.tabs.map((t) =>
t.id === tabId ? { ...t, layout: newLayout } : t t.id === tabId ? { ...t, layout: newLayout } : t
); );
@@ -1971,7 +2085,7 @@ export const useAppStore = create<AppState & AppActions>()(
}), }),
{ {
name: "automaker-storage", 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) => { migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Partial<AppState>; const state = persistedState as Partial<AppState>;
@@ -1983,6 +2097,21 @@ export const useAppStore = create<AppState & AppActions>()(
} }
} }
// 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; return state as AppState;
}, },
partialize: (state) => ({ partialize: (state) => ({

View File

@@ -203,6 +203,24 @@ export type AutoModeEvent =
projectId?: string; projectId?: string;
projectPath?: 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"; type: "auto_mode_phase";
featureId: string; featureId: string;

View File

@@ -30,7 +30,12 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js"; import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js"; import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.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 { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js"; import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js"; import { AutoModeService } from "./services/auto-mode-service.js";
@@ -64,7 +69,9 @@ if (!hasAnthropicKey && !hasOAuthToken) {
╚═══════════════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════════════╝
`); `);
} else if (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 { } else {
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)"); 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 // Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on("upgrade", (request, socket, head) => { 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") { if (pathname === "/api/events") {
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
@@ -171,152 +181,198 @@ wss.on("connection", (ws: WebSocket) => {
const terminalConnections: Map<string, Set<WebSocket>> = new Map(); const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Terminal WebSocket connection handler // Terminal WebSocket connection handler
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => { terminalWss.on(
// Parse URL to get session ID and token "connection",
const url = new URL(req.url || "", `http://${req.headers.host}`); (ws: WebSocket, req: import("http").IncomingMessage) => {
const sessionId = url.searchParams.get("sessionId"); // Parse URL to get session ID and token
const token = url.searchParams.get("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 // Check if terminal is enabled
if (!isTerminalEnabled()) { if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled"); console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled"); ws.close(4003, "Terminal access is disabled");
return; 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 }));
} }
});
// Subscribe to terminal exit // Validate token if password is required
const unsubscribeExit = terminalService.onExit((sid, exitCode) => { if (
if (sid === sessionId && ws.readyState === WebSocket.OPEN) { isTerminalPasswordRequired() &&
ws.send(JSON.stringify({ type: "exit", exitCode })); !validateTerminalToken(token || undefined)
ws.close(1000, "Session ended"); ) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
} }
});
// Handle incoming messages if (!sessionId) {
ws.on("message", (message) => { console.log("[Terminal WS] No session ID provided");
try { ws.close(4002, "Session ID required");
const msg = JSON.parse(message.toString()); return;
}
switch (msg.type) { // Check if session exists
case "input": const session = terminalService.getSession(sessionId);
// Write user input to terminal if (!session) {
terminalService.write(sessionId, msg.data); console.log(`[Terminal WS] Session ${sessionId} not found`);
break; ws.close(4004, "Session not found");
return;
}
case "resize": console.log(`[Terminal WS] Client connected to session ${sessionId}`);
// Resize terminal
if (msg.cols && msg.rows) {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
case "ping": // Track this connection
// Respond to ping if (!terminalConnections.has(sessionId)) {
ws.send(JSON.stringify({ type: "pong" })); terminalConnections.set(sessionId, new Set());
break; }
terminalConnections.get(sessionId)!.add(ws);
default: // Subscribe to terminal data
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`); 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", () => { // Subscribe to terminal exit
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`); const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
unsubscribeData(); if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
unsubscribeExit(); ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
} }
});
// 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 // Start server with error handling for port conflicts
server.listen(PORT, () => { const startServer = (port: number) => {
const terminalStatus = isTerminalEnabled() server.listen(port, () => {
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled") const terminalStatus = isTerminalEnabled()
: "disabled"; ? isTerminalPasswordRequired()
console.log(` ? "enabled (password protected)"
: "enabled"
: "disabled";
const portStr = port.toString().padEnd(4);
console.log(`
╔═══════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║ ║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣ ╠═══════════════════════════════════════════════════════╣
║ HTTP API: http://localhost:${PORT} ║ HTTP API: http://localhost:${portStr}
║ WebSocket: ws://localhost:${PORT}/api/events ║ WebSocket: ws://localhost:${portStr}/api/events ║
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║ Terminal: ws://localhost:${portStr}/api/terminal/ws ║
║ Health: http://localhost:${PORT}/api/health ║ Health: http://localhost:${portStr}/api/health ║
║ Terminal: ${terminalStatus.padEnd(37)} ║ 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 // Graceful shutdown
process.on("SIGTERM", () => { process.on("SIGTERM", () => {

View File

@@ -26,6 +26,12 @@ export function initAllowedPaths(): void {
if (dataDir) { if (dataDir) {
allowedPaths.add(path.resolve(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); const resolved = path.resolve(filePath);
if (!isPathAllowed(resolved)) { 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; return resolved;

View File

@@ -7,7 +7,10 @@
import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { BaseProvider } from "./base-provider.js"; import { BaseProvider } from "./base-provider.js";
import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js"; import {
convertHistoryToMessages,
normalizeContentBlocks,
} from "../lib/conversation-utils.js";
import type { import type {
ExecuteOptions, ExecuteOptions,
ProviderMessage, ProviderMessage,
@@ -23,7 +26,9 @@ export class ClaudeProvider extends BaseProvider {
/** /**
* Execute a query using Claude Agent SDK * Execute a query using Claude Agent SDK
*/ */
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { async *executeQuery(
options: ExecuteOptions
): AsyncGenerator<ProviderMessage> {
const { const {
prompt, prompt,
model, model,
@@ -36,21 +41,24 @@ export class ClaudeProvider extends BaseProvider {
} = options; } = options;
// Build Claude SDK options // Build Claude SDK options
const defaultTools = [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
];
const toolsToUse = allowedTools || defaultTools;
const sdkOptions: Options = { const sdkOptions: Options = {
model, model,
systemPrompt, systemPrompt,
maxTurns, maxTurns,
cwd, cwd,
allowedTools: allowedTools || [ allowedTools: toolsToUse,
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
permissionMode: "acceptEdits", permissionMode: "acceptEdits",
sandbox: { sandbox: {
enabled: true, enabled: true,
@@ -60,32 +68,68 @@ export class ClaudeProvider extends BaseProvider {
}; };
// Build prompt payload with conversation history // Build prompt payload with conversation history
let promptPayload: string | AsyncGenerator<any, void, unknown>; let promptPayload: string | AsyncGenerator<any, void, unknown> | Array<any>;
if (conversationHistory && conversationHistory.length > 0) { if (conversationHistory && conversationHistory.length > 0) {
// Multi-turn conversation with history // Multi-turn conversation with history
promptPayload = (async function* () { // Convert history to SDK message format
// Yield history messages using utility // Note: When using async generator, SDK only accepts SDKUserMessage (type: 'user')
const historyMessages = convertHistoryToMessages(conversationHistory); // So we filter to only include user messages to avoid SDK errors
for (const msg of historyMessages) { const historyMessages = convertHistoryToMessages(conversationHistory);
yield msg; const hasAssistantMessages = historyMessages.some(
} (msg) => msg.type === "assistant"
);
// Yield current prompt if (hasAssistantMessages) {
yield { // If we have assistant messages, use async generator but filter to only user messages
type: "user" as const, // This maintains conversation flow while respecting SDK type constraints
session_id: "", promptPayload = (async function* () {
message: { // Filter to only user messages - SDK async generator only accepts SDKUserMessage
role: "user" as const, const userHistoryMessages = historyMessages.filter(
content: normalizeContentBlocks(prompt), (msg) => msg.type === "user"
}, );
parent_tool_use_id: null, 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)) { } else if (Array.isArray(prompt)) {
// Multi-part prompt (with images) - no history // Multi-part prompt (with images) - no history
promptPayload = (async function* () { promptPayload = (async function* () {
yield { const multiPartPrompt = {
type: "user" as const, type: "user" as const,
session_id: "", session_id: "",
message: { message: {
@@ -94,6 +138,7 @@ export class ClaudeProvider extends BaseProvider {
}, },
parent_tool_use_id: null, parent_tool_use_id: null,
}; };
yield multiPartPrompt;
})(); })();
} else { } else {
// Simple text prompt - no history // Simple text prompt - no history
@@ -101,11 +146,19 @@ export class ClaudeProvider extends BaseProvider {
} }
// Execute via Claude Agent SDK // 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 // Stream messages directly - they're already in the correct format
for await (const msg of stream) { for await (const msg of stream) {
yield msg as ProviderMessage; 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<InstallationStatus> { async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency // Claude SDK is always available since it's a dependency
const hasApiKey = const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
!!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN; const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
const hasApiKey = hasAnthropicKey || hasOAuthToken;
return { const status: InstallationStatus = {
installed: true, installed: true,
method: "sdk", method: "sdk",
hasApiKey, hasApiKey,
authenticated: hasApiKey, authenticated: hasApiKey,
}; };
return status;
} }
/** /**
* Get available Claude models * Get available Claude models
*/ */
getAvailableModels(): ModelDefinition[] { getAvailableModels(): ModelDefinition[] {
return [ const models = [
{ {
id: "claude-opus-4-5-20251101", id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5", name: "Claude Opus 4.5",
@@ -140,7 +196,7 @@ export class ClaudeProvider extends BaseProvider {
maxOutputTokens: 16000, maxOutputTokens: 16000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "premium", tier: "premium" as const,
default: true, default: true,
}, },
{ {
@@ -153,7 +209,7 @@ export class ClaudeProvider extends BaseProvider {
maxOutputTokens: 16000, maxOutputTokens: 16000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "standard", tier: "standard" as const,
}, },
{ {
id: "claude-3-5-sonnet-20241022", id: "claude-3-5-sonnet-20241022",
@@ -165,7 +221,7 @@ export class ClaudeProvider extends BaseProvider {
maxOutputTokens: 8000, maxOutputTokens: 8000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "standard", tier: "standard" as const,
}, },
{ {
id: "claude-3-5-haiku-20241022", id: "claude-3-5-haiku-20241022",
@@ -177,9 +233,10 @@ export class ClaudeProvider extends BaseProvider {
maxOutputTokens: 8000, maxOutputTokens: 8000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "basic", tier: "basic" as const,
}, },
]; ] satisfies ModelDefinition[];
return models;
} }
/** /**

View File

@@ -7,7 +7,11 @@ import { Router, type Request, type Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import os from "os"; import os from "os";
import path from "path"; 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"; import type { EventEmitter } from "../lib/events.js";
export function createFsRoutes(_events: EventEmitter): Router { export function createFsRoutes(_events: EventEmitter): Router {
@@ -69,9 +73,41 @@ export function createFsRoutes(_events: EventEmitter): Router {
return; 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 }); await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths so subsequent operations work
addAllowedPath(resolvedPath);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown 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); const stats = await fs.stat(resolvedPath);
if (!stats.isDirectory()) { 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; return;
} }
@@ -229,7 +267,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
}; };
if (!directoryName) { if (!directoryName) {
res.status(400).json({ success: false, error: "directoryName is required" }); res
.status(400)
.json({ success: false, error: "directoryName is required" });
return; return;
} }
@@ -254,10 +294,16 @@ export function createFsRoutes(_events: EventEmitter): Router {
const searchPaths: string[] = [ const searchPaths: string[] = [
process.cwd(), // Current working directory process.cwd(), // Current working directory
process.env.HOME || process.env.USERPROFILE || "", // User home 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"), path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"),
// Common project locations // Common project locations
path.join(process.env.HOME || process.env.USERPROFILE || "", "Projects"), path.join(
process.env.HOME || process.env.USERPROFILE || "",
"Projects"
),
].filter(Boolean); ].filter(Boolean);
// Also check parent of current working directory // Also check parent of current working directory
@@ -275,7 +321,7 @@ export function createFsRoutes(_events: EventEmitter): Router {
try { try {
const candidatePath = path.join(searchPath, directoryName); const candidatePath = path.join(searchPath, directoryName);
const stats = await fs.stat(candidatePath); const stats = await fs.stat(candidatePath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
// Verify it matches by checking for sample files // Verify it matches by checking for sample files
if (sampleFiles && sampleFiles.length > 0) { if (sampleFiles && sampleFiles.length > 0) {
@@ -284,8 +330,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
// Remove directory name prefix from sample file path // Remove directory name prefix from sample file path
const relativeFile = sampleFile.startsWith(directoryName + "/") const relativeFile = sampleFile.startsWith(directoryName + "/")
? sampleFile.substring(directoryName.length + 1) ? sampleFile.substring(directoryName.length + 1)
: sampleFile.split("/").slice(1).join("/") || sampleFile.split("/").pop() || sampleFile; : sampleFile.split("/").slice(1).join("/") ||
sampleFile.split("/").pop() ||
sampleFile;
try { try {
const filePath = path.join(candidatePath, relativeFile); const filePath = path.join(candidatePath, relativeFile);
await fs.access(filePath); await fs.access(filePath);
@@ -294,7 +342,7 @@ export function createFsRoutes(_events: EventEmitter): Router {
// File doesn't exist, continue checking // File doesn't exist, continue checking
} }
} }
// If at least one file matches, consider it a match // If at least one file matches, consider it a match
if (matches === 0 && sampleFiles.length > 0) { if (matches === 0 && sampleFiles.length > 0) {
continue; // Try next candidate continue; // Try next candidate
@@ -405,7 +453,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
const stats = await fs.stat(targetPath); const stats = await fs.stat(targetPath);
if (!stats.isDirectory()) { 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; return;
} }
@@ -438,7 +488,8 @@ export function createFsRoutes(_events: EventEmitter): Router {
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error instanceof Error ? error.message : "Failed to read directory", error:
error instanceof Error ? error.message : "Failed to read directory",
}); });
} }
} catch (error) { } catch (error) {
@@ -464,8 +515,8 @@ export function createFsRoutes(_events: EventEmitter): Router {
const fullPath = path.isAbsolute(imagePath) const fullPath = path.isAbsolute(imagePath)
? imagePath ? imagePath
: projectPath : projectPath
? path.join(projectPath, imagePath) ? path.join(projectPath, imagePath)
: imagePath; : imagePath;
// Check if file exists // Check if file exists
try { try {
@@ -490,7 +541,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
".bmp": "image/bmp", ".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.setHeader("Cache-Control", "public, max-age=3600");
res.send(buffer); res.send(buffer);
} catch (error) { } catch (error) {
@@ -546,38 +600,42 @@ export function createFsRoutes(_events: EventEmitter): Router {
}); });
// Delete board background image // Delete board background image
router.post("/delete-board-background", async (req: Request, res: Response) => { router.post(
try { "/delete-board-background",
const { projectPath } = req.body as { projectPath: string }; async (req: Request, res: Response) => {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
const boardDir = path.join(projectPath, ".automaker", "board");
try { try {
// Try to remove all files in the board directory const { projectPath } = req.body as { projectPath: string };
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 }); if (!projectPath) {
} catch (error) { res.status(400).json({
const message = error instanceof Error ? error.message : "Unknown error"; success: false,
res.status(500).json({ success: false, error: message }); 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 // Browse directories for file picker
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows // 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); const normalizedHome = path.resolve(homeDir);
// Allow browsing within home directory // Allow browsing within home directory
if (resolved === normalizedHome || resolved.startsWith(normalizedHome + path.sep)) { if (
resolved === normalizedHome ||
resolved.startsWith(normalizedHome + path.sep)
) {
return true; return true;
} }
@@ -646,7 +707,8 @@ export function createFsRoutes(_events: EventEmitter): Router {
if (!isSafePath(targetPath)) { if (!isSafePath(targetPath)) {
res.status(403).json({ res.status(403).json({
success: false, 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; return;
} }
@@ -655,7 +717,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
const stats = await fs.stat(targetPath); const stats = await fs.stat(targetPath);
if (!stats.isDirectory()) { 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; return;
} }
@@ -688,7 +752,8 @@ export function createFsRoutes(_events: EventEmitter): Router {
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error instanceof Error ? error.message : "Failed to read directory", error:
error instanceof Error ? error.message : "Failed to read directory",
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -9,9 +9,7 @@ import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js"; import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js"; import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js"; import type { ExecuteOptions } from "../providers/types.js";
import { import { readImageAsBase64 } from "../lib/image-handler.js";
readImageAsBase64,
} from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js"; import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { getEffectiveModel } from "../lib/model-resolver.js"; import { getEffectiveModel } from "../lib/model-resolver.js";
import { isAbortError } from "../lib/error-handler.js"; import { isAbortError } from "../lib/error-handler.js";
@@ -136,7 +134,10 @@ export class AgentService {
filename: imageData.filename, filename: imageData.filename,
}); });
} catch (error) { } 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", "WebFetch",
], ],
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,
}; };
// Build prompt content with images // Build prompt content with images
@@ -381,7 +383,11 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`); const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try { 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); await this.updateSessionTimestamp(sessionId);
} catch (error) { } catch (error) {
console.error("[AgentService] Failed to save session:", error); console.error("[AgentService] Failed to save session:", error);
@@ -398,7 +404,11 @@ export class AgentService {
} }
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> { async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
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<void> { async updateSessionTimestamp(sessionId: string): Promise<void> {
@@ -418,7 +428,8 @@ export class AgentService {
} }
return sessions.sort( 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; return true;
} }
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void { private emitAgentEvent(
sessionId: string,
data: Record<string, unknown>
): void {
this.events.emit("agent:stream", { sessionId, ...data }); this.events.emit("agent:stream", { sessionId, ...data });
} }

View File

@@ -32,7 +32,15 @@ interface Feature {
priority?: number; priority?: number;
spec?: string; spec?: string;
model?: string; // Model to use for this feature model?: string; // Model to use for this feature
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>; imagePaths?: Array<
| string
| {
path: string;
filename?: string;
mimeType?: string;
[key: string]: unknown;
}
>;
} }
interface RunningFeature { interface RunningFeature {
@@ -78,7 +86,7 @@ export class AutoModeService {
projectPath, projectPath,
}; };
this.emitAutoModeEvent("auto_mode_complete", { this.emitAutoModeEvent("auto_mode_started", {
message: `Auto mode started with max ${maxConcurrency} concurrent features`, message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath, projectPath,
}); });
@@ -111,8 +119,9 @@ export class AutoModeService {
); );
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", { this.emitAutoModeEvent("auto_mode_idle", {
message: "No pending features - auto mode idle", message: "No pending features - auto mode idle",
projectPath: this.config!.projectPath,
}); });
await this.sleep(10000); await this.sleep(10000);
continue; continue;
@@ -143,21 +152,27 @@ export class AutoModeService {
} }
this.autoLoopRunning = false; this.autoLoopRunning = false;
this.emitAutoModeEvent("auto_mode_complete", {
message: "Auto mode stopped",
});
} }
/** /**
* Stop the auto mode loop * Stop the auto mode loop
*/ */
async stopAutoLoop(): Promise<number> { async stopAutoLoop(): Promise<number> {
const wasRunning = this.autoLoopRunning;
this.autoLoopRunning = false; this.autoLoopRunning = false;
if (this.autoLoopAbortController) { if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort(); this.autoLoopAbortController.abort();
this.autoLoopAbortController = null; 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; return this.runningFeatures.size;
} }
@@ -230,10 +245,19 @@ export class AutoModeService {
// Get model from feature // Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); 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 // 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 // Mark as waiting_approval for user review
await this.updateFeatureStatus( await this.updateFeatureStatus(
@@ -422,7 +446,9 @@ Address the follow-up instructions above. Review the previous work and make the
try { try {
// Get model from feature (already loaded above) // Get model from feature (already loaded above)
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); 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 // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress"); await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -458,9 +484,11 @@ Address the follow-up instructions above. Review the previous work and make the
filename filename
); );
copiedImagePaths.push(relativePath); copiedImagePaths.push(relativePath);
} catch (error) { } 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 +534,14 @@ Address the follow-up instructions above. Review the previous work and make the
} }
// Use fullPrompt (already built above) with model and all images // 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 // Mark as waiting_approval for user review
await this.updateFeatureStatus( await this.updateFeatureStatus(
@@ -717,7 +752,10 @@ Format your response as a structured markdown document.`;
try { try {
// Use default Claude model for analysis (can be overridden in the future) // 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 provider = ProviderFactory.getProviderForModel(analysisModel);
const options: ExecuteOptions = { const options: ExecuteOptions = {
@@ -917,7 +955,11 @@ Format your response as a structured markdown document.`;
try { try {
const data = await fs.readFile(featurePath, "utf-8"); const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data); 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); features.push(feature);
} }
} catch { } catch {
@@ -998,9 +1040,15 @@ ${feature.spec}
const imagesList = feature.imagePaths const imagesList = feature.imagePaths
.map((img, idx) => { .map((img, idx) => {
const path = typeof img === "string" ? img : img.path; const path = typeof img === "string" ? img : img.path;
const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop(); const filename =
const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*"; typeof img === "string"
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; ? 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"); .join("\n");
@@ -1038,7 +1086,9 @@ When done, summarize what you implemented and any notes for the developer.`;
model?: string model?: string
): Promise<void> { ): Promise<void> {
const finalModel = resolveModelString(model, DEFAULT_MODELS.claude); 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 // Get provider for this model
const provider = ProviderFactory.getProviderForModel(finalModel); const provider = ProviderFactory.getProviderForModel(finalModel);
@@ -1060,14 +1110,7 @@ When done, summarize what you implemented and any notes for the developer.`;
model: finalModel, model: finalModel,
maxTurns: 50, maxTurns: 50,
cwd: workDir, cwd: workDir,
allowedTools: [ allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
],
abortController, abortController,
}; };
@@ -1089,12 +1132,15 @@ When done, summarize what you implemented and any notes for the developer.`;
responseText = block.text || ""; responseText = block.text || "";
// Check for authentication errors in the response // 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("authentication_failed") ||
block.text.includes("Fix external API key"))) { block.text.includes("Fix external API key"))
) {
throw new Error( throw new Error(
"Authentication failed: Invalid or expired API key. " + "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."
); );
} }

View File

@@ -119,7 +119,10 @@ describe("auto-mode-service.ts (integration)", () => {
); );
// Verify feature status was updated to backlog (error status) // 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"); expect(feature?.status).toBe("backlog");
}, 30000); }, 30000);
@@ -154,7 +157,10 @@ describe("auto-mode-service.ts (integration)", () => {
); );
// Feature should be updated successfully // 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"); expect(feature?.status).toBe("waiting_approval");
}, 30000); }, 30000);
}); });
@@ -313,7 +319,9 @@ describe("auto-mode-service.ts (integration)", () => {
); );
// Should have used claude-sonnet-4-20250514 // Should have used claude-sonnet-4-20250514
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514"); expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith(
"claude-sonnet-4-20250514"
);
}, 30000); }, 30000);
}); });
@@ -447,9 +455,11 @@ describe("auto-mode-service.ts (integration)", () => {
await service.stopAutoLoop(); await service.stopAutoLoop();
await startPromise.catch(() => {}); await startPromise.catch(() => {});
// Check stop event was emitted (auto_mode_complete event) // Check stop event was emitted (emitted immediately by stopAutoLoop)
const stopEvent = mockEvents.emit.mock.calls.find((call) => const stopEvent = mockEvents.emit.mock.calls.find(
call[1]?.type === "auto_mode_complete" || call[1]?.message?.includes("stopped") (call) =>
call[1]?.type === "auto_mode_stopped" ||
call[1]?.message?.includes("Auto mode stopped")
); );
expect(stopEvent).toBeTruthy(); expect(stopEvent).toBeTruthy();
}, 10000); }, 10000);
@@ -476,12 +486,7 @@ describe("auto-mode-service.ts (integration)", () => {
); );
// Should not throw // Should not throw
await service.executeFeature( await service.executeFeature(testRepo.path, "error-feature", true, false);
testRepo.path,
"error-feature",
true,
false
);
// Feature should be marked as backlog (error status) // Feature should be marked as backlog (error status)
const feature = await featureLoader.get(testRepo.path, "error-feature"); const feature = await featureLoader.get(testRepo.path, "error-feature");

117
init.sh
View File

@@ -33,43 +33,38 @@ fi
echo -e "${YELLOW}Checking Playwright browsers...${NC}" echo -e "${YELLOW}Checking Playwright browsers...${NC}"
npx playwright install chromium 2>/dev/null || true 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 # Kill any existing processes on required ports
echo -e "${YELLOW}Checking for processes on ports 3007 and 3008...${NC}" echo -e "${YELLOW}Checking for processes on ports 3007 and 3008...${NC}"
lsof -ti:3007 | xargs kill -9 2>/dev/null || true kill_port 3007
lsof -ti:3008 | xargs kill -9 2>/dev/null || true kill_port 3008
# 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}"
echo "" echo ""
# Prompt user for application mode # Prompt user for application mode
@@ -81,12 +76,59 @@ echo " 2) Desktop Application (Electron)"
echo "═══════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════"
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 while true; do
read -p "Enter your choice (1 or 2): " choice read -p "Enter your choice (1 or 2): " choice
case $choice in case $choice in
1) 1)
echo "" echo ""
echo -e "${BLUE}Launching Web Application...${NC}" 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 "The application will be available at: ${GREEN}http://localhost:3007${NC}"
echo "" echo ""
npm run dev:web npm run dev:web
@@ -95,6 +137,8 @@ while true; do
2) 2)
echo "" echo ""
echo -e "${BLUE}Launching Desktop Application...${NC}" echo -e "${BLUE}Launching Desktop Application...${NC}"
echo -e "${YELLOW}(Electron will start its own backend server)${NC}"
echo ""
npm run dev:electron npm run dev:electron
break break
;; ;;
@@ -103,6 +147,3 @@ while true; do
;; ;;
esac esac
done done
# Cleanup on exit
trap "echo 'Cleaning up...'; kill $SERVER_PID 2>/dev/null || true; exit" INT TERM EXIT

1
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": { "": {
"name": "automaker", "name": "automaker",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"libs/*" "libs/*"

View File

@@ -7,6 +7,7 @@
"libs/*" "libs/*"
], ],
"scripts": { "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": "npm run dev --workspace=apps/app",
"dev:web": "npm run dev:web --workspace=apps/app", "dev:web": "npm run dev:web --workspace=apps/app",
"dev:electron": "npm run dev:electron --workspace=apps/app", "dev:electron": "npm run dev:electron --workspace=apps/app",