/** * HTTP API Client for web mode * * This client provides the same API as the Electron IPC bridge, * but communicates with the backend server via HTTP/WebSocket. */ import { createLogger } from '@automaker/utils/logger'; import type { ElectronAPI, FileResult, WriteResult, ReaddirResult, StatResult, DialogResult, SaveImageResult, AutoModeAPI, FeaturesAPI, SuggestionsAPI, SpecRegenerationAPI, AutoModeEvent, SuggestionsEvent, SpecRegenerationEvent, SuggestionType, GitHubAPI, IssueValidationInput, IssueValidationEvent, IdeationAPI, IdeaCategory, AnalysisSuggestion, StartSessionOptions, CreateIdeaInput, UpdateIdeaInput, ConvertToFeatureOptions, NotificationsAPI, EventHistoryAPI, } from './electron'; import type { EventHistoryFilter } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; const logger = createLogger('HttpClient'); const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; // Cached server URL (set during initialization in Electron mode) let cachedServerUrl: string | null = null; /** * Notify the UI that the current session is no longer valid. * Used to redirect the user to a logged-out route on 401/403 responses. */ const notifyLoggedOut = (): void => { if (typeof window === 'undefined') return; try { window.dispatchEvent(new CustomEvent('automaker:logged-out')); } catch { // Ignore - navigation will still be handled by failed requests in most cases } }; /** * Handle an unauthorized response in cookie/session auth flows. * Clears in-memory token and attempts to clear the cookie (best-effort), * then notifies the UI to redirect. */ const handleUnauthorized = (): void => { clearSessionToken(); // Best-effort cookie clear (avoid throwing) fetch(`${getServerUrl()}/api/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}', cache: NO_STORE_CACHE_MODE, }).catch(() => {}); notifyLoggedOut(); }; /** * Notify the UI that the server is offline/unreachable. * Used to redirect the user to the login page which will show server unavailable. */ const notifyServerOffline = (): void => { if (typeof window === 'undefined') return; try { window.dispatchEvent(new CustomEvent('automaker:server-offline')); } catch { // Ignore } }; /** * Check if an error is a connection error (server offline/unreachable). * These are typically TypeError with 'Failed to fetch' or similar network errors. */ export const isConnectionError = (error: unknown): boolean => { if (error instanceof TypeError) { const message = error.message.toLowerCase(); return ( message.includes('failed to fetch') || message.includes('network') || message.includes('econnrefused') || message.includes('connection refused') ); } // Check for error objects with message property if (error && typeof error === 'object' && 'message' in error) { const message = String((error as { message: unknown }).message).toLowerCase(); return ( message.includes('failed to fetch') || message.includes('network') || message.includes('econnrefused') || message.includes('connection refused') ); } return false; }; /** * Handle a server offline error by notifying the UI to redirect. * Call this when a connection error is detected. */ export const handleServerOffline = (): void => { logger.error('Server appears to be offline, redirecting to login...'); notifyServerOffline(); }; /** * Initialize server URL from Electron IPC. * Must be called early in Electron mode before making API requests. */ export const initServerUrl = async (): Promise => { // window.electronAPI is typed as ElectronAPI, but some Electron-only helpers // (like getServerUrl) are not part of the shared interface. Narrow via `any`. const electron = typeof window !== 'undefined' ? (window.electronAPI as any) : null; if (electron?.getServerUrl) { try { cachedServerUrl = await electron.getServerUrl(); logger.info('Server URL from Electron:', cachedServerUrl); } catch (error) { logger.warn('Failed to get server URL from Electron:', error); } } }; // Server URL - uses cached value from IPC or environment variable const getServerUrl = (): string => { // Use cached URL from Electron IPC if available if (cachedServerUrl) { return cachedServerUrl; } if (typeof window !== 'undefined') { const envUrl = import.meta.env.VITE_SERVER_URL; if (envUrl) return envUrl; // In web mode (not Electron), use relative URL to leverage Vite proxy // This avoids CORS issues since requests appear same-origin if (!window.electron) { return ''; } } // Use VITE_HOSTNAME if set, otherwise default to localhost const hostname = import.meta.env.VITE_HOSTNAME || 'localhost'; return `http://${hostname}:3008`; }; /** * Get the server URL (exported for use in other modules) */ export const getServerUrlSync = (): string => getServerUrl(); // Cached API key for authentication (Electron mode only) let cachedApiKey: string | null = null; let apiKeyInitialized = false; let apiKeyInitPromise: Promise | null = null; // Cached session token for authentication (Web mode - explicit header auth) // Persisted to localStorage to survive page reloads let cachedSessionToken: string | null = null; const SESSION_TOKEN_KEY = 'automaker:sessionToken'; // Initialize cached session token from localStorage on module load // This ensures web mode survives page reloads with valid authentication const initSessionToken = (): void => { if (typeof window === 'undefined') return; // Skip in SSR try { cachedSessionToken = window.localStorage.getItem(SESSION_TOKEN_KEY); } catch { // localStorage might be disabled or unavailable cachedSessionToken = null; } }; // Initialize on module load initSessionToken(); // Get API key for Electron mode (returns cached value after initialization) // Exported for use in WebSocket connections that need auth export const getApiKey = (): string | null => cachedApiKey; /** * Wait for API key initialization to complete. * Returns immediately if already initialized. */ export const waitForApiKeyInit = (): Promise => { if (apiKeyInitialized) return Promise.resolve(); if (apiKeyInitPromise) return apiKeyInitPromise; // If not started yet, start it now return initApiKey(); }; // Get session token for Web mode (returns cached value after login) export const getSessionToken = (): string | null => cachedSessionToken; // Set session token (called after login) - persists to localStorage for page reload survival export const setSessionToken = (token: string | null): void => { cachedSessionToken = token; if (typeof window === 'undefined') return; // Skip in SSR try { if (token) { window.localStorage.setItem(SESSION_TOKEN_KEY, token); } else { window.localStorage.removeItem(SESSION_TOKEN_KEY); } } catch { // localStorage might be disabled; continue with in-memory cache } }; // Clear session token (called on logout) export const clearSessionToken = (): void => { cachedSessionToken = null; if (typeof window === 'undefined') return; // Skip in SSR try { window.localStorage.removeItem(SESSION_TOKEN_KEY); } catch { // localStorage might be disabled } }; /** * Check if we're running in Electron mode */ export const isElectronMode = (): boolean => { if (typeof window === 'undefined') return false; // Prefer a stable runtime marker from preload. // In some dev/electron setups, method availability can be temporarily undefined // during early startup, but `isElectron` remains reliable. const api = window.electronAPI as any; return api?.isElectron === true || !!api?.getApiKey; }; // Cached external server mode flag let cachedExternalServerMode: boolean | null = null; /** * Check if running in external server mode (Docker API) * In this mode, Electron uses session-based auth like web mode */ export const checkExternalServerMode = async (): Promise => { if (cachedExternalServerMode !== null) { return cachedExternalServerMode; } if (typeof window !== 'undefined') { const api = window.electronAPI as any; if (api?.isExternalServerMode) { try { cachedExternalServerMode = Boolean(await api.isExternalServerMode()); return cachedExternalServerMode; } catch (error) { logger.warn('Failed to check external server mode:', error); } } } cachedExternalServerMode = false; return false; }; /** * Get cached external server mode (synchronous, returns null if not yet checked) */ export const isExternalServerMode = (): boolean | null => cachedExternalServerMode; /** * Initialize API key and server URL for Electron mode authentication. * In web mode, authentication uses HTTP-only cookies instead. * * This should be called early in app initialization. */ export const initApiKey = async (): Promise => { // Return existing promise if already in progress if (apiKeyInitPromise) return apiKeyInitPromise; // Return immediately if already initialized if (apiKeyInitialized) return; // Create and store the promise so concurrent calls wait for the same initialization apiKeyInitPromise = (async () => { try { // Initialize server URL from Electron IPC first (needed for API requests) await initServerUrl(); // Only Electron mode uses API key header auth if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { try { cachedApiKey = await window.electronAPI.getApiKey(); if (cachedApiKey) { logger.info('Using API key from Electron'); return; } } catch (error) { logger.warn('Failed to get API key from Electron:', error); } } // In web mode, authentication is handled via HTTP-only cookies logger.info('Web mode - using cookie-based authentication'); } finally { // Mark as initialized after completion, regardless of success or failure apiKeyInitialized = true; } })(); return apiKeyInitPromise; }; /** * Check authentication status with the server */ export const checkAuthStatus = async (): Promise<{ authenticated: boolean; required: boolean; }> => { try { const response = await fetch(`${getServerUrl()}/api/auth/status`, { credentials: 'include', headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined, cache: NO_STORE_CACHE_MODE, }); const data = await response.json(); return { authenticated: data.authenticated ?? false, required: data.required ?? true, }; } catch (error) { logger.error('Failed to check auth status:', error); return { authenticated: false, required: true }; } }; /** * Login with API key (for web mode) * After login succeeds, verifies the session is actually working by making * a request to an authenticated endpoint. */ export const login = async ( apiKey: string ): Promise<{ success: boolean; error?: string; token?: string }> => { try { const response = await fetch(`${getServerUrl()}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ apiKey }), cache: NO_STORE_CACHE_MODE, }); const data = await response.json(); // Store the session token if login succeeded if (data.success && data.token) { setSessionToken(data.token); logger.info('Session token stored after login'); // Verify the session is actually working by making a request to an authenticated endpoint const verified = await verifySession(); if (!verified) { logger.error('Login appeared successful but session verification failed'); return { success: false, error: 'Session verification failed. Please try again.', }; } logger.info('Login verified successfully'); } return data; } catch (error) { logger.error('Login failed:', error); return { success: false, error: 'Network error' }; } }; /** * Check if the session cookie is still valid by making a request to an authenticated endpoint. * Note: This does NOT retrieve the session token - on page refresh we rely on cookies alone. * The session token is only available after a fresh login. */ export const fetchSessionToken = async (): Promise => { // On page refresh, we can't retrieve the session token (it's stored in HTTP-only cookie). // We just verify the cookie is valid by checking auth status. // The session token is only stored in memory after a fresh login. try { const response = await fetch(`${getServerUrl()}/api/auth/status`, { credentials: 'include', // Send the session cookie cache: NO_STORE_CACHE_MODE, }); if (!response.ok) { logger.info('Failed to check auth status'); return false; } const data = await response.json(); if (data.success && data.authenticated) { logger.info('Session cookie is valid'); return true; } logger.info('Session cookie is not authenticated'); return false; } catch (error) { logger.error('Failed to check session:', error); return false; } }; /** * Logout (for web mode) */ export const logout = async (): Promise<{ success: boolean }> => { try { const response = await fetch(`${getServerUrl()}/api/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', cache: NO_STORE_CACHE_MODE, }); // Clear the cached session token clearSessionToken(); logger.info('Session token cleared on logout'); return await response.json(); } catch (error) { logger.error('Logout failed:', error); return { success: false }; } }; /** * Verify that the current session is still valid by making a request to an authenticated endpoint. * If the session has expired or is invalid, clears the session and returns false. * This should be called: * 1. After login to verify the cookie was set correctly * 2. On app load to verify the session hasn't expired * * Returns: * - true: Session is valid * - false: Session is definitively invalid (401/403 auth failure) * - throws: Network error or server not ready (caller should retry) */ export const verifySession = async (): Promise => { const headers: Record = { 'Content-Type': 'application/json', }; // Electron mode: use API key header const apiKey = getApiKey(); if (apiKey) { headers['X-API-Key'] = apiKey; } // Add session token header if available (web mode) const sessionToken = getSessionToken(); if (sessionToken) { headers['X-Session-Token'] = sessionToken; } // Make a request to an authenticated endpoint to verify the session // We use /api/settings/status as it requires authentication and is lightweight // Note: fetch throws on network errors, which we intentionally let propagate const response = await fetch(`${getServerUrl()}/api/settings/status`, { headers, credentials: 'include', cache: NO_STORE_CACHE_MODE, // Avoid hanging indefinitely during backend reloads or network issues signal: AbortSignal.timeout(2500), }); // Check for authentication errors - these are definitive "invalid session" responses if (response.status === 401 || response.status === 403) { logger.warn('Session verification failed - session expired or invalid'); // Clear the in-memory/localStorage session token since it's no longer valid // Note: We do NOT call logout here - that would destroy a potentially valid // cookie if the issue was transient (e.g., token not sent due to timing) clearSessionToken(); return false; } // For other non-ok responses (5xx, etc.), throw to trigger retry if (!response.ok) { const error = new Error(`Session verification failed with status: ${response.status}`); logger.warn('Session verification failed with status:', response.status); throw error; } logger.info('Session verified successfully'); return true; }; /** * Check if the server is running in a containerized (sandbox) environment. * This endpoint is unauthenticated so it can be checked before login. */ export const checkSandboxEnvironment = async (): Promise<{ isContainerized: boolean; skipSandboxWarning?: boolean; error?: string; }> => { try { const response = await fetch(`${getServerUrl()}/api/health/environment`, { method: 'GET', cache: NO_STORE_CACHE_MODE, signal: AbortSignal.timeout(5000), }); if (!response.ok) { logger.warn('Failed to check sandbox environment'); return { isContainerized: false, error: 'Failed to check environment' }; } const data = await response.json(); return { isContainerized: data.isContainerized ?? false, skipSandboxWarning: data.skipSandboxWarning ?? false, }; } catch (error) { logger.error('Sandbox environment check failed:', error); return { isContainerized: false, error: 'Network error' }; } }; type EventType = | 'agent:stream' | 'auto-mode:event' | 'suggestions:event' | 'spec-regeneration:event' | 'issue-validation:event' | 'backlog-plan:event' | 'ideation:stream' | 'ideation:analysis' | 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed' | 'dev-server:started' | 'dev-server:output' | 'dev-server:stopped' | 'notification:created'; /** * Dev server log event payloads for WebSocket streaming */ export interface DevServerStartedEvent { worktreePath: string; port: number; url: string; timestamp: string; } export interface DevServerOutputEvent { worktreePath: string; content: string; timestamp: string; } export interface DevServerStoppedEvent { worktreePath: string; port: number; exitCode: number | null; error?: string; timestamp: string; } export type DevServerLogEvent = | { type: 'dev-server:started'; payload: DevServerStartedEvent } | { type: 'dev-server:output'; payload: DevServerOutputEvent } | { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }; /** * Response type for fetching dev server logs */ export interface DevServerLogsResponse { success: boolean; result?: { worktreePath: string; port: number; url: string; logs: string; startedAt: string; }; error?: string; } type EventCallback = (payload: unknown) => void; interface EnhancePromptResult { success: boolean; enhancedText?: string; error?: string; } /** * HTTP API Client that implements ElectronAPI interface */ export class HttpApiClient implements ElectronAPI { private serverUrl: string; private ws: WebSocket | null = null; private eventCallbacks: Map> = new Map(); private reconnectTimer: NodeJS.Timeout | null = null; private isConnecting = false; constructor() { this.serverUrl = getServerUrl(); // Electron mode: connect WebSocket immediately once API key is ready. // Web mode: defer WebSocket connection until a consumer subscribes to events, // to avoid noisy 401s on first-load/login/setup routes. if (isElectronMode()) { waitForApiKeyInit() .then(() => { this.connectWebSocket(); }) .catch((error) => { logger.error('API key initialization failed:', error); // Still attempt WebSocket connection - it may work with cookie auth this.connectWebSocket(); }); } } /** * Fetch a short-lived WebSocket token from the server * Used for secure WebSocket authentication without exposing session tokens in URLs */ private async fetchWsToken(): Promise { try { const headers: Record = { 'Content-Type': 'application/json', }; // Add session token header if available const sessionToken = getSessionToken(); if (sessionToken) { headers['X-Session-Token'] = sessionToken; } const response = await fetch(`${this.serverUrl}/api/auth/token`, { headers, credentials: 'include', cache: NO_STORE_CACHE_MODE, }); if (response.status === 401 || response.status === 403) { handleUnauthorized(); return null; } if (!response.ok) { logger.warn('Failed to fetch wsToken:', response.status); return null; } const data = await response.json(); if (data.success && data.token) { return data.token; } return null; } catch (error) { logger.error('Error fetching wsToken:', error); return null; } } private connectWebSocket(): void { if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { return; } this.isConnecting = true; // Wait for API key initialization to complete before attempting connection // This prevents race conditions during app startup waitForApiKeyInit() .then(() => this.doConnectWebSocketInternal()) .catch((error) => { logger.error('Failed to initialize for WebSocket connection:', error); this.isConnecting = false; }); } private doConnectWebSocketInternal(): void { // Electron mode typically authenticates with the injected API key. // However, in external-server/cookie-auth flows, the API key may be unavailable. // In that case, fall back to the same wsToken/cookie authentication used in web mode // so the UI still receives real-time events (running tasks, logs, etc.). if (isElectronMode()) { const apiKey = getApiKey(); if (!apiKey) { logger.warn('Electron mode: API key missing, attempting wsToken/cookie auth for WebSocket'); this.fetchWsToken() .then((wsToken) => { const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; if (wsToken) { this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`); } else { // Fallback: try connecting without token (will fail if not authenticated) logger.warn('No wsToken available, attempting WebSocket connection anyway'); this.establishWebSocket(wsUrl); } }) .catch((error) => { logger.error('Failed to prepare WebSocket connection (electron fallback):', error); this.isConnecting = false; }); return; } const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`); return; } // In web mode, fetch a short-lived wsToken first this.fetchWsToken() .then((wsToken) => { const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; if (wsToken) { this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`); } else { // Fallback: try connecting without token (will fail if not authenticated) logger.warn('No wsToken available, attempting connection anyway'); this.establishWebSocket(wsUrl); } }) .catch((error) => { logger.error('Failed to prepare WebSocket connection:', error); this.isConnecting = false; }); } /** * Establish the actual WebSocket connection */ private establishWebSocket(wsUrl: string): void { try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { logger.info('WebSocket connected'); this.isConnecting = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } }; this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); logger.info( 'WebSocket message:', data.type, 'hasPayload:', !!data.payload, 'callbacksRegistered:', this.eventCallbacks.has(data.type) ); const callbacks = this.eventCallbacks.get(data.type); if (callbacks) { logger.info('Dispatching to', callbacks.size, 'callbacks'); callbacks.forEach((cb) => cb(data.payload)); } } catch (error) { logger.error('Failed to parse WebSocket message:', error); } }; this.ws.onclose = () => { logger.info('WebSocket disconnected'); this.isConnecting = false; this.ws = null; // Attempt to reconnect after 5 seconds if (!this.reconnectTimer) { this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connectWebSocket(); }, 5000); } }; this.ws.onerror = (error) => { logger.error('WebSocket error:', error); this.isConnecting = false; }; } catch (error) { logger.error('Failed to create WebSocket:', error); this.isConnecting = false; } } private subscribeToEvent(type: EventType, callback: EventCallback): () => void { if (!this.eventCallbacks.has(type)) { this.eventCallbacks.set(type, new Set()); } this.eventCallbacks.get(type)!.add(callback); // Ensure WebSocket is connected this.connectWebSocket(); return () => { const callbacks = this.eventCallbacks.get(type); if (callbacks) { callbacks.delete(callback); } }; } private getHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', }; // Electron mode: use API key const apiKey = getApiKey(); if (apiKey) { headers['X-API-Key'] = apiKey; return headers; } // Web mode: use session token if available const sessionToken = getSessionToken(); if (sessionToken) { headers['X-Session-Token'] = sessionToken; } return headers; } private async post(endpoint: string, body?: unknown): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'POST', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401 || response.status === 403) { handleUnauthorized(); throw new Error('Unauthorized'); } if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = await response.json(); if (errorData.error) { errorMessage = errorData.error; } } catch { // If parsing JSON fails, use status text } throw new Error(errorMessage); } return response.json(); } private async get(endpoint: string): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth cache: NO_STORE_CACHE_MODE, }); if (response.status === 401 || response.status === 403) { handleUnauthorized(); throw new Error('Unauthorized'); } if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = await response.json(); if (errorData.error) { errorMessage = errorData.error; } } catch { // If parsing JSON fails, use status text } throw new Error(errorMessage); } return response.json(); } private async put(endpoint: string, body?: unknown): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'PUT', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401 || response.status === 403) { handleUnauthorized(); throw new Error('Unauthorized'); } if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = await response.json(); if (errorData.error) { errorMessage = errorData.error; } } catch { // If parsing JSON fails, use status text } throw new Error(errorMessage); } return response.json(); } private async httpDelete(endpoint: string, body?: unknown): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401 || response.status === 403) { handleUnauthorized(); throw new Error('Unauthorized'); } if (!response.ok) { let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = await response.json(); if (errorData.error) { errorMessage = errorData.error; } } catch { // If parsing JSON fails, use status text } throw new Error(errorMessage); } return response.json(); } // Basic operations async ping(): Promise { const result = await this.get<{ status: string }>('/api/health'); return result.status === 'ok' ? 'pong' : 'error'; } async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> { // Open in new tab window.open(url, '_blank', 'noopener,noreferrer'); return { success: true }; } async openInEditor( filePath: string, line?: number, column?: number ): Promise<{ success: boolean; error?: string }> { // Build VS Code URL scheme: vscode://file/path:line:column // This works on systems where VS Code's URL handler is registered // URL encode the path to handle special characters (spaces, brackets, etc.) // Handle both Unix (/) and Windows (\) path separators const normalizedPath = filePath.replace(/\\/g, '/'); const encodedPath = normalizedPath.startsWith('/') ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; if (column !== undefined && column > 0) { url += `:${column}`; } } try { // Use anchor click approach which is most reliable for custom URL schemes // This triggers the browser's URL handler without navigation issues const anchor = document.createElement('a'); anchor.href = url; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to open in editor', }; } } // File picker - uses server-side file browser dialog async openDirectory(): Promise { const fileBrowser = getGlobalFileBrowser(); if (!fileBrowser) { logger.error('File browser not initialized'); return { canceled: true, filePaths: [] }; } const path = await fileBrowser(); if (!path) { return { canceled: true, filePaths: [] }; } // Validate with server const result = await this.post<{ success: boolean; path?: string; isAllowed?: boolean; error?: string; }>('/api/fs/validate-path', { filePath: path }); if (result.success && result.path && result.isAllowed !== false) { return { canceled: false, filePaths: [result.path] }; } logger.error('Invalid directory:', result.error || 'Path not allowed'); return { canceled: true, filePaths: [] }; } async openFile(_options?: object): Promise { const fileBrowser = getGlobalFileBrowser(); if (!fileBrowser) { logger.error('File browser not initialized'); return { canceled: true, filePaths: [] }; } // For now, use the same directory browser (could be enhanced for file selection) const path = await fileBrowser(); if (!path) { return { canceled: true, filePaths: [] }; } const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', { filePath: path, }); if (result.success && result.exists) { return { canceled: false, filePaths: [path] }; } logger.error('File not found'); return { canceled: true, filePaths: [] }; } // File system operations async readFile(filePath: string): Promise { return this.post('/api/fs/read', { filePath }); } async writeFile(filePath: string, content: string): Promise { return this.post('/api/fs/write', { filePath, content }); } async mkdir(dirPath: string): Promise { return this.post('/api/fs/mkdir', { dirPath }); } async readdir(dirPath: string): Promise { return this.post('/api/fs/readdir', { dirPath }); } async exists(filePath: string): Promise { const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', { filePath, }); return result.exists; } async stat(filePath: string): Promise { return this.post('/api/fs/stat', { filePath }); } async deleteFile(filePath: string): Promise { return this.post('/api/fs/delete', { filePath }); } async trashItem(filePath: string): Promise { // In web mode, trash is just delete return this.deleteFile(filePath); } async getPath(name: string): Promise { // Server provides data directory if (name === 'userData') { const result = await this.get<{ dataDir: string }>('/api/health/detailed'); return result.dataDir || '/data'; } return `/data/${name}`; } async saveImageToTemp( data: string, filename: string, mimeType: string, projectPath?: string ): Promise { return this.post('/api/fs/save-image', { data, filename, mimeType, projectPath, }); } async saveBoardBackground( data: string, filename: string, mimeType: string, projectPath: string ): Promise<{ success: boolean; path?: string; error?: string }> { return this.post('/api/fs/save-board-background', { data, filename, mimeType, projectPath, }); } async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> { return this.post('/api/fs/delete-board-background', { projectPath }); } // CLI checks - server-side async checkClaudeCli(): Promise<{ success: boolean; status?: string; method?: string; version?: string; path?: string; recommendation?: string; installCommands?: { macos?: string; windows?: string; linux?: string; npm?: string; }; error?: string; }> { return this.get('/api/setup/claude-status'); } // Model API model = { getAvailable: async (): Promise<{ success: boolean; models?: ModelDefinition[]; error?: string; }> => { return this.get('/api/models/available'); }, checkProviders: async (): Promise<{ success: boolean; providers?: Record; error?: string; }> => { return this.get('/api/models/providers'); }, }; // Setup API setup = { getClaudeStatus: (): Promise<{ success: boolean; status?: string; installed?: boolean; method?: string; version?: string; path?: string; auth?: { authenticated: boolean; method: string; hasCredentialsFile?: boolean; hasToken?: boolean; hasStoredOAuthToken?: boolean; hasStoredApiKey?: boolean; hasEnvApiKey?: boolean; hasEnvOAuthToken?: boolean; hasCliAuth?: boolean; hasRecentActivity?: boolean; }; error?: string; }> => this.get('/api/setup/claude-status'), installClaude: (): Promise<{ success: boolean; message?: string; error?: string; }> => this.post('/api/setup/install-claude'), authClaude: (): Promise<{ success: boolean; token?: string; requiresManualAuth?: boolean; terminalOpened?: boolean; command?: string; error?: string; message?: string; output?: string; }> => this.post('/api/setup/auth-claude'), deauthClaude: (): Promise<{ success: boolean; requiresManualDeauth?: boolean; command?: string; message?: string; error?: string; }> => this.post('/api/setup/deauth-claude'), storeApiKey: ( provider: string, apiKey: string ): Promise<{ success: boolean; error?: string; }> => this.post('/api/setup/store-api-key', { provider, apiKey }), deleteApiKey: ( provider: string ): Promise<{ success: boolean; error?: string; message?: string; }> => this.post('/api/setup/delete-api-key', { provider }), getApiKeys: (): Promise<{ success: boolean; hasAnthropicKey: boolean; hasGoogleKey: boolean; hasOpenaiKey: boolean; }> => this.get('/api/setup/api-keys'), getPlatform: (): Promise<{ success: boolean; platform: string; arch: string; homeDir: string; isWindows: boolean; isMac: boolean; isLinux: boolean; }> => this.get('/api/setup/platform'), verifyClaudeAuth: ( authMethod?: 'cli' | 'api_key', apiKey?: string ): Promise<{ success: boolean; authenticated: boolean; error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }), getGhStatus: (): Promise<{ success: boolean; installed: boolean; authenticated: boolean; version: string | null; path: string | null; user: string | null; error?: string; }> => this.get('/api/setup/gh-status'), // Cursor CLI methods getCursorStatus: (): Promise<{ success: boolean; installed?: boolean; version?: string | null; path?: string | null; auth?: { authenticated: boolean; method: string; }; installCommand?: string; loginCommand?: string; error?: string; }> => this.get('/api/setup/cursor-status'), authCursor: (): Promise<{ success: boolean; token?: string; requiresManualAuth?: boolean; terminalOpened?: boolean; command?: string; message?: string; output?: string; }> => this.post('/api/setup/auth-cursor'), deauthCursor: (): Promise<{ success: boolean; requiresManualDeauth?: boolean; command?: string; message?: string; error?: string; }> => this.post('/api/setup/deauth-cursor'), authOpencode: (): Promise<{ success: boolean; token?: string; requiresManualAuth?: boolean; terminalOpened?: boolean; command?: string; message?: string; output?: string; }> => this.post('/api/setup/auth-opencode'), deauthOpencode: (): Promise<{ success: boolean; requiresManualDeauth?: boolean; command?: string; message?: string; error?: string; }> => this.post('/api/setup/deauth-opencode'), getCursorConfig: ( projectPath: string ): Promise<{ success: boolean; config?: { defaultModel?: string; models?: string[]; mcpServers?: string[]; rules?: string[]; }; availableModels?: Array<{ id: string; label: string; description: string; hasThinking: boolean; tier: 'free' | 'pro'; }>; error?: string; }> => this.get(`/api/setup/cursor-config?projectPath=${encodeURIComponent(projectPath)}`), setCursorDefaultModel: ( projectPath: string, model: string ): Promise<{ success: boolean; model?: string; error?: string; }> => this.post('/api/setup/cursor-config/default-model', { projectPath, model }), setCursorModels: ( projectPath: string, models: string[] ): Promise<{ success: boolean; models?: string[]; error?: string; }> => this.post('/api/setup/cursor-config/models', { projectPath, models }), // Cursor CLI Permissions getCursorPermissions: ( projectPath?: string ): Promise<{ success: boolean; globalPermissions?: { allow: string[]; deny: string[] } | null; projectPermissions?: { allow: string[]; deny: string[] } | null; effectivePermissions?: { allow: string[]; deny: string[] } | null; activeProfile?: 'strict' | 'development' | 'custom' | null; hasProjectConfig?: boolean; availableProfiles?: Array<{ id: string; name: string; description: string; permissions: { allow: string[]; deny: string[] }; }>; error?: string; }> => this.get( `/api/setup/cursor-permissions${projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : ''}` ), applyCursorPermissionProfile: ( profileId: 'strict' | 'development', scope: 'global' | 'project', projectPath?: string ): Promise<{ success: boolean; message?: string; scope?: string; profileId?: string; error?: string; }> => this.post('/api/setup/cursor-permissions/profile', { profileId, scope, projectPath }), setCursorCustomPermissions: ( projectPath: string, permissions: { allow: string[]; deny: string[] } ): Promise<{ success: boolean; message?: string; permissions?: { allow: string[]; deny: string[] }; error?: string; }> => this.post('/api/setup/cursor-permissions/custom', { projectPath, permissions }), deleteCursorProjectPermissions: ( projectPath: string ): Promise<{ success: boolean; message?: string; error?: string; }> => this.httpDelete( `/api/setup/cursor-permissions?projectPath=${encodeURIComponent(projectPath)}` ), getCursorExampleConfig: ( profileId?: 'strict' | 'development' ): Promise<{ success: boolean; profileId?: string; config?: string; error?: string; }> => this.get( `/api/setup/cursor-permissions/example${profileId ? `?profileId=${profileId}` : ''}` ), // Codex CLI methods getCodexStatus: (): Promise<{ success: boolean; status?: string; installed?: boolean; method?: string; version?: string; path?: string; auth?: { authenticated: boolean; method: string; hasAuthFile?: boolean; hasOAuthToken?: boolean; hasApiKey?: boolean; hasStoredApiKey?: boolean; hasEnvApiKey?: boolean; }; error?: string; }> => this.get('/api/setup/codex-status'), installCodex: (): Promise<{ success: boolean; message?: string; error?: string; }> => this.post('/api/setup/install-codex'), authCodex: (): Promise<{ success: boolean; token?: string; requiresManualAuth?: boolean; terminalOpened?: boolean; command?: string; error?: string; message?: string; output?: string; }> => this.post('/api/setup/auth-codex'), deauthCodex: (): Promise<{ success: boolean; requiresManualDeauth?: boolean; command?: string; message?: string; error?: string; }> => this.post('/api/setup/deauth-codex'), verifyCodexAuth: ( authMethod: 'cli' | 'api_key', apiKey?: string ): Promise<{ success: boolean; authenticated: boolean; error?: string; }> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }), // OpenCode CLI methods getOpencodeStatus: (): Promise<{ success: boolean; status?: string; installed?: boolean; method?: string; version?: string; path?: string; recommendation?: string; installCommands?: { macos?: string; linux?: string; npm?: string; }; auth?: { authenticated: boolean; method: string; hasAuthFile?: boolean; hasOAuthToken?: boolean; hasApiKey?: boolean; hasStoredApiKey?: boolean; hasEnvApiKey?: boolean; }; error?: string; }> => this.get('/api/setup/opencode-status'), // OpenCode Dynamic Model Discovery getOpencodeModels: ( refresh?: boolean ): Promise<{ success: boolean; models?: Array<{ id: string; name: string; modelString: string; provider: string; description: string; supportsTools: boolean; supportsVision: boolean; tier: string; default?: boolean; }>; count?: number; cached?: boolean; error?: string; }> => this.get(`/api/setup/opencode/models${refresh ? '?refresh=true' : ''}`), refreshOpencodeModels: (): Promise<{ success: boolean; models?: Array<{ id: string; name: string; modelString: string; provider: string; description: string; supportsTools: boolean; supportsVision: boolean; tier: string; default?: boolean; }>; count?: number; error?: string; }> => this.post('/api/setup/opencode/models/refresh'), getOpencodeProviders: (): Promise<{ success: boolean; providers?: Array<{ id: string; name: string; authenticated: boolean; authMethod?: 'oauth' | 'api_key'; }>; authenticated?: Array<{ id: string; name: string; authenticated: boolean; authMethod?: 'oauth' | 'api_key'; }>; error?: string; }> => this.get('/api/setup/opencode/providers'), clearOpencodeCache: (): Promise<{ success: boolean; message?: string; error?: string; }> => this.post('/api/setup/opencode/cache/clear'), onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, onAuthProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, }; // Features API features: FeaturesAPI & { bulkUpdate: ( projectPath: string, featureIds: string[], updates: Partial ) => Promise<{ success: boolean; updatedCount?: number; failedCount?: number; results?: Array<{ featureId: string; success: boolean; error?: string }>; features?: Feature[]; error?: string; }>; bulkDelete: ( projectPath: string, featureIds: string[] ) => Promise<{ success: boolean; deletedCount?: number; failedCount?: number; results?: Array<{ featureId: string; success: boolean; error?: string }>; error?: string; }>; } = { getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }), get: (projectPath: string, featureId: string) => this.post('/api/features/get', { projectPath, featureId }), create: (projectPath: string, feature: Feature) => this.post('/api/features/create', { projectPath, feature }), update: ( projectPath: string, featureId: string, updates: Partial, descriptionHistorySource?: 'enhance' | 'edit', enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer', preEnhancementDescription?: string ) => this.post('/api/features/update', { projectPath, featureId, updates, descriptionHistorySource, enhancementMode, preEnhancementDescription, }), delete: (projectPath: string, featureId: string) => this.post('/api/features/delete', { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => this.post('/api/features/agent-output', { projectPath, featureId }), generateTitle: (description: string) => this.post('/api/features/generate-title', { description }), bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) => this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), bulkDelete: (projectPath: string, featureIds: string[]) => this.post('/api/features/bulk-delete', { projectPath, featureIds }), }; // Auto Mode API autoMode: AutoModeAPI = { start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) => this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }), stop: (projectPath: string, branchName?: string | null) => this.post('/api/auto-mode/stop', { projectPath, branchName }), stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }), status: (projectPath?: string, branchName?: string | null) => this.post('/api/auto-mode/status', { projectPath, branchName }), runFeature: ( projectPath: string, featureId: string, useWorktrees?: boolean, worktreePath?: string ) => this.post('/api/auto-mode/run-feature', { projectPath, featureId, useWorktrees, worktreePath, }), verifyFeature: (projectPath: string, featureId: string) => this.post('/api/auto-mode/verify-feature', { projectPath, featureId }), resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => this.post('/api/auto-mode/resume-feature', { projectPath, featureId, useWorktrees, }), contextExists: (projectPath: string, featureId: string) => this.post('/api/auto-mode/context-exists', { projectPath, featureId }), analyzeProject: (projectPath: string) => this.post('/api/auto-mode/analyze-project', { projectPath }), followUpFeature: ( projectPath: string, featureId: string, prompt: string, imagePaths?: string[], useWorktrees?: boolean ) => this.post('/api/auto-mode/follow-up-feature', { projectPath, featureId, prompt, imagePaths, useWorktrees, }), commitFeature: (projectPath: string, featureId: string, worktreePath?: string) => this.post('/api/auto-mode/commit-feature', { projectPath, featureId, worktreePath, }), approvePlan: ( projectPath: string, featureId: string, approved: boolean, editedPlan?: string, feedback?: string ) => this.post('/api/auto-mode/approve-plan', { projectPath, featureId, approved, editedPlan, feedback, }), resumeInterrupted: (projectPath: string) => this.post('/api/auto-mode/resume-interrupted', { projectPath }), onEvent: (callback: (event: AutoModeEvent) => void) => { return this.subscribeToEvent('auto-mode:event', callback as EventCallback); }, }; // Enhance Prompt API enhancePrompt = { enhance: ( originalText: string, enhancementMode: string, model?: string, thinkingLevel?: string ): Promise => this.post('/api/enhance-prompt', { originalText, enhancementMode, model, thinkingLevel, }), }; // Worktree API worktree: WorktreeAPI = { mergeFeature: ( projectPath: string, branchName: string, worktreePath: string, options?: object ) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }), getInfo: (projectPath: string, featureId: string) => this.post('/api/worktree/info', { projectPath, featureId }), getStatus: (projectPath: string, featureId: string) => this.post('/api/worktree/status', { projectPath, featureId }), list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }), listAll: (projectPath: string, includeDetails?: boolean, forceRefreshGitHub?: boolean) => this.post('/api/worktree/list', { projectPath, includeDetails, forceRefreshGitHub }), create: (projectPath: string, branchName: string, baseBranch?: string) => this.post('/api/worktree/create', { projectPath, branchName, baseBranch, }), delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) => this.post('/api/worktree/delete', { projectPath, worktreePath, deleteBranch, }), commit: (worktreePath: string, message: string) => this.post('/api/worktree/commit', { worktreePath, message }), generateCommitMessage: (worktreePath: string) => this.post('/api/worktree/generate-commit-message', { worktreePath }), push: (worktreePath: string, force?: boolean) => this.post('/api/worktree/push', { worktreePath, force }), createPR: (worktreePath: string, options?: any) => this.post('/api/worktree/create-pr', { worktreePath, ...options }), getDiffs: (projectPath: string, featureId: string) => this.post('/api/worktree/diffs', { projectPath, featureId }), getFileDiff: (projectPath: string, featureId: string, filePath: string) => this.post('/api/worktree/file-diff', { projectPath, featureId, filePath, }), pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }), checkoutBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/checkout-branch', { worktreePath, branchName }), listBranches: (worktreePath: string, includeRemote?: boolean) => this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), getAvailableEditors: () => this.get('/api/worktree/available-editors'), refreshEditors: () => this.post('/api/worktree/refresh-editors', {}), getAvailableTerminals: () => this.get('/api/worktree/available-terminals'), getDefaultTerminal: () => this.get('/api/worktree/default-terminal'), refreshTerminals: () => this.post('/api/worktree/refresh-terminals', {}), openInExternalTerminal: (worktreePath: string, terminalId?: string) => this.post('/api/worktree/open-in-external-terminal', { worktreePath, terminalId }), initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }), startDevServer: (projectPath: string, worktreePath: string) => this.post('/api/worktree/start-dev', { projectPath, worktreePath }), stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }), listDevServers: () => this.post('/api/worktree/list-dev-servers', {}), getDevServerLogs: (worktreePath: string): Promise => this.get(`/api/worktree/dev-server-logs?worktreePath=${encodeURIComponent(worktreePath)}`), onDevServerLogEvent: (callback: (event: DevServerLogEvent) => void) => { const unsub1 = this.subscribeToEvent('dev-server:started', (payload) => callback({ type: 'dev-server:started', payload: payload as DevServerStartedEvent }) ); const unsub2 = this.subscribeToEvent('dev-server:output', (payload) => callback({ type: 'dev-server:output', payload: payload as DevServerOutputEvent }) ); const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) => callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent }) ); return () => { unsub1(); unsub2(); unsub3(); }; }, getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), // Init script methods getInitScript: (projectPath: string) => this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`), setInitScript: (projectPath: string, content: string) => this.put('/api/worktree/init-script', { projectPath, content }), deleteInitScript: (projectPath: string) => this.httpDelete('/api/worktree/init-script', { projectPath }), runInitScript: (projectPath: string, worktreePath: string, branch: string) => this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; payload: unknown; }) => void ) => { // Note: subscribeToEvent callback receives (payload) not (_, payload) const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) => callback({ type: 'worktree:init-started', payload }) ); const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) => callback({ type: 'worktree:init-output', payload }) ); const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) => callback({ type: 'worktree:init-completed', payload }) ); return () => { unsub1(); unsub2(); unsub3(); }; }, }; // Git API git: GitAPI = { getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }), getFileDiff: (projectPath: string, filePath: string) => this.post('/api/git/file-diff', { projectPath, filePath }), }; // Suggestions API suggestions: SuggestionsAPI = { generate: ( projectPath: string, suggestionType?: SuggestionType, model?: string, thinkingLevel?: string ) => this.post('/api/suggestions/generate', { projectPath, suggestionType, model, thinkingLevel }), stop: () => this.post('/api/suggestions/stop'), status: () => this.get('/api/suggestions/status'), onEvent: (callback: (event: SuggestionsEvent) => void) => { return this.subscribeToEvent('suggestions:event', callback as EventCallback); }, }; // Spec Regeneration API specRegeneration: SpecRegenerationAPI = { create: ( projectPath: string, projectOverview: string, generateFeatures?: boolean, analyzeProject?: boolean, maxFeatures?: number ) => this.post('/api/spec-regeneration/create', { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures, }), generate: ( projectPath: string, projectDefinition: string, generateFeatures?: boolean, analyzeProject?: boolean, maxFeatures?: number ) => this.post('/api/spec-regeneration/generate', { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures, }), generateFeatures: (projectPath: string, maxFeatures?: number) => this.post('/api/spec-regeneration/generate-features', { projectPath, maxFeatures, }), sync: (projectPath: string) => this.post('/api/spec-regeneration/sync', { projectPath }), stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }), status: (projectPath?: string) => this.get( projectPath ? `/api/spec-regeneration/status?projectPath=${encodeURIComponent(projectPath)}` : '/api/spec-regeneration/status' ), onEvent: (callback: (event: SpecRegenerationEvent) => void) => { return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback); }, }; // Running Agents API runningAgents = { getAll: (): Promise<{ success: boolean; runningAgents?: Array<{ featureId: string; projectPath: string; projectName: string; isAutoMode: boolean; }>; totalCount?: number; error?: string; }> => this.get('/api/running-agents'), }; // GitHub API github: GitHubAPI = { checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), validateIssue: ( projectPath: string, issue: IssueValidationInput, model?: ModelId, thinkingLevel?: ThinkingLevel, reasoningEffort?: ReasoningEffort ) => this.post('/api/github/validate-issue', { projectPath, ...issue, model, thinkingLevel, reasoningEffort, }), getValidationStatus: (projectPath: string, issueNumber?: number) => this.post('/api/github/validation-status', { projectPath, issueNumber }), stopValidation: (projectPath: string, issueNumber: number) => this.post('/api/github/validation-stop', { projectPath, issueNumber }), getValidations: (projectPath: string, issueNumber?: number) => this.post('/api/github/validations', { projectPath, issueNumber }), markValidationViewed: (projectPath: string, issueNumber: number) => this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }), onValidationEvent: (callback: (event: IssueValidationEvent) => void) => this.subscribeToEvent('issue-validation:event', callback as EventCallback), getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) => this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }), }; // Workspace API workspace = { getConfig: (): Promise<{ success: boolean; configured: boolean; workspaceDir?: string; defaultDir?: string | null; error?: string; }> => this.get('/api/workspace/config'), getDirectories: (): Promise<{ success: boolean; directories?: Array<{ name: string; path: string }>; error?: string; }> => this.get('/api/workspace/directories'), }; // Agent API agent = { start: ( sessionId: string, workingDirectory?: string ): Promise<{ success: boolean; messages?: Message[]; error?: string; }> => this.post('/api/agent/start', { sessionId, workingDirectory }), send: ( sessionId: string, message: string, workingDirectory?: string, imagePaths?: string[], model?: string, thinkingLevel?: string ): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/send', { sessionId, message, workingDirectory, imagePaths, model, thinkingLevel, }), getHistory: ( sessionId: string ): Promise<{ success: boolean; messages?: Message[]; isRunning?: boolean; error?: string; }> => this.post('/api/agent/history', { sessionId }), stop: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/stop', { sessionId }), clear: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/clear', { sessionId }), onStream: (callback: (data: unknown) => void): (() => void) => { return this.subscribeToEvent('agent:stream', callback as EventCallback); }, // Queue management queueAdd: ( sessionId: string, message: string, imagePaths?: string[], model?: string, thinkingLevel?: string ): Promise<{ success: boolean; queuedPrompt?: { id: string; message: string; imagePaths?: string[]; model?: string; thinkingLevel?: string; addedAt: string; }; error?: string; }> => this.post('/api/agent/queue/add', { sessionId, message, imagePaths, model, thinkingLevel }), queueList: ( sessionId: string ): Promise<{ success: boolean; queue?: Array<{ id: string; message: string; imagePaths?: string[]; model?: string; thinkingLevel?: string; addedAt: string; }>; error?: string; }> => this.post('/api/agent/queue/list', { sessionId }), queueRemove: ( sessionId: string, promptId: string ): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/queue/remove', { sessionId, promptId }), queueClear: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/queue/clear', { sessionId }), }; // Templates API templates = { clone: ( repoUrl: string, projectName: string, parentDir: string ): Promise<{ success: boolean; projectPath?: string; projectName?: string; error?: string; }> => this.post('/api/templates/clone', { repoUrl, projectName, parentDir }), }; // Settings API - persistent file-based settings settings = { // Get settings status (check if migration needed) getStatus: (): Promise<{ success: boolean; hasGlobalSettings: boolean; hasCredentials: boolean; dataDir: string; needsMigration: boolean; }> => this.get('/api/settings/status'), // Global settings getGlobal: (): Promise<{ success: boolean; settings?: { version: number; theme: string; sidebarOpen: boolean; chatHistoryOpen: boolean; maxConcurrency: number; defaultSkipTests: boolean; enableDependencyBlocking: boolean; useWorktrees: boolean; defaultPlanningMode: string; defaultRequirePlanApproval: boolean; muteDoneSound: boolean; enhancementModel: string; keyboardShortcuts: Record; projects: unknown[]; trashedProjects: unknown[]; projectHistory: string[]; projectHistoryIndex: number; lastProjectDir?: string; recentFolders: string[]; worktreePanelCollapsed: boolean; lastSelectedSessionByProject: Record; mcpServers?: Array<{ id: string; name: string; description?: string; type?: 'stdio' | 'sse' | 'http'; command?: string; args?: string[]; env?: Record; url?: string; headers?: Record; enabled?: boolean; }>; }; error?: string; }> => this.get('/api/settings/global'), updateGlobal: ( updates: Record ): Promise<{ success: boolean; settings?: Record; error?: string; }> => this.put('/api/settings/global', updates), // Credentials (masked for security) getCredentials: (): Promise<{ success: boolean; credentials?: { anthropic: { configured: boolean; masked: string }; google: { configured: boolean; masked: string }; openai: { configured: boolean; masked: string }; }; error?: string; }> => this.get('/api/settings/credentials'), updateCredentials: (updates: { apiKeys?: { anthropic?: string; google?: string; openai?: string }; }): Promise<{ success: boolean; credentials?: { anthropic: { configured: boolean; masked: string }; google: { configured: boolean; masked: string }; openai: { configured: boolean; masked: string }; }; error?: string; }> => this.put('/api/settings/credentials', updates), // Project settings getProject: ( projectPath: string ): Promise<{ success: boolean; settings?: { version: number; theme?: string; useWorktrees?: boolean; currentWorktree?: { path: string | null; branch: string }; worktrees?: Array<{ path: string; branch: string; isMain: boolean; hasChanges?: boolean; changedFilesCount?: number; }>; boardBackground?: { imagePath: string | null; imageVersion?: number; cardOpacity: number; columnOpacity: number; columnBorderEnabled: boolean; cardGlassmorphism: boolean; cardBorderEnabled: boolean; cardBorderOpacity: number; hideScrollbar: boolean; }; worktreePanelVisible?: boolean; showInitScriptIndicator?: boolean; defaultDeleteBranchWithWorktree?: boolean; autoDismissInitScriptIndicator?: boolean; lastSelectedSessionId?: string; }; error?: string; }> => this.post('/api/settings/project', { projectPath }), updateProject: ( projectPath: string, updates: Record ): Promise<{ success: boolean; settings?: Record; error?: string; }> => this.put('/api/settings/project', { projectPath, updates }), // Migration from localStorage migrate: (data: { 'automaker-storage'?: string; 'automaker-setup'?: string; 'worktree-panel-collapsed'?: string; 'file-browser-recent-folders'?: string; 'automaker:lastProjectDir'?: string; }): Promise<{ success: boolean; migratedGlobalSettings: boolean; migratedCredentials: boolean; migratedProjectCount: number; errors: string[]; }> => this.post('/api/settings/migrate', { data }), // Filesystem agents discovery (read-only) discoverAgents: ( projectPath?: string, sources?: Array<'user' | 'project'> ): Promise<{ success: boolean; agents?: Array<{ name: string; definition: { description: string; prompt: string; tools?: string[]; model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; }; source: 'user' | 'project'; filePath: string; }>; error?: string; }> => this.post('/api/settings/agents/discover', { projectPath, sources }), }; // Sessions API sessions = { list: ( includeArchived?: boolean ): Promise<{ success: boolean; sessions?: SessionListItem[]; error?: string; }> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`), create: ( name: string, projectPath: string, workingDirectory?: string ): Promise<{ success: boolean; session?: { id: string; name: string; projectPath: string; workingDirectory?: string; createdAt: string; updatedAt: string; }; error?: string; }> => this.post('/api/sessions', { name, projectPath, workingDirectory }), update: ( sessionId: string, name?: string, tags?: string[] ): Promise<{ success: boolean; error?: string }> => this.put(`/api/sessions/${sessionId}`, { name, tags }), archive: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/archive`, {}), unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/unarchive`, {}), delete: (sessionId: string): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; // Claude API claude = { getUsage: (): Promise => this.get('/api/claude/usage'), }; // Codex API codex = { getUsage: (): Promise => this.get('/api/codex/usage'), getModels: ( refresh = false ): Promise<{ success: boolean; models?: Array<{ id: string; label: string; description: string; hasThinking: boolean; supportsVision: boolean; tier: 'premium' | 'standard' | 'basic'; isDefault: boolean; }>; cachedAt?: number; error?: string; }> => { const url = `/api/codex/models${refresh ? '?refresh=true' : ''}`; return this.get(url); }, }; // Context API context = { describeImage: ( imagePath: string ): Promise<{ success: boolean; description?: string; error?: string; }> => this.post('/api/context/describe-image', { imagePath }), describeFile: ( filePath: string ): Promise<{ success: boolean; description?: string; error?: string; }> => this.post('/api/context/describe-file', { filePath }), }; // Backlog Plan API backlogPlan = { generate: ( projectPath: string, prompt: string, model?: string ): Promise<{ success: boolean; error?: string }> => this.post('/api/backlog-plan/generate', { projectPath, prompt, model }), stop: (): Promise<{ success: boolean; error?: string }> => this.post('/api/backlog-plan/stop', {}), status: ( projectPath: string ): Promise<{ success: boolean; isRunning?: boolean; savedPlan?: { savedAt: string; prompt: string; model?: string; result: { changes: Array<{ type: 'add' | 'update' | 'delete'; featureId?: string; feature?: Record; reason: string; }>; summary: string; dependencyUpdates: Array<{ featureId: string; removedDependencies: string[]; addedDependencies: string[]; }>; }; } | null; error?: string; }> => this.get(`/api/backlog-plan/status?projectPath=${encodeURIComponent(projectPath)}`), apply: ( projectPath: string, plan: { changes: Array<{ type: 'add' | 'update' | 'delete'; featureId?: string; feature?: Record; reason: string; }>; summary: string; dependencyUpdates: Array<{ featureId: string; removedDependencies: string[]; addedDependencies: string[]; }>; }, branchName?: string ): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> => this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }), clear: (projectPath: string): Promise<{ success: boolean; error?: string }> => this.post('/api/backlog-plan/clear', { projectPath }), onEvent: (callback: (data: unknown) => void): (() => void) => { return this.subscribeToEvent('backlog-plan:event', callback as EventCallback); }, }; // Ideation API - brainstorming and idea management ideation: IdeationAPI = { startSession: (projectPath: string, options?: StartSessionOptions) => this.post('/api/ideation/session/start', { projectPath, options }), getSession: (projectPath: string, sessionId: string) => this.post('/api/ideation/session/get', { projectPath, sessionId }), sendMessage: ( sessionId: string, message: string, options?: { imagePaths?: string[]; model?: string } ) => this.post('/api/ideation/session/message', { sessionId, message, options }), stopSession: (sessionId: string) => this.post('/api/ideation/session/stop', { sessionId }), listIdeas: (projectPath: string) => this.post('/api/ideation/ideas/list', { projectPath }), createIdea: (projectPath: string, idea: CreateIdeaInput) => this.post('/api/ideation/ideas/create', { projectPath, idea }), getIdea: (projectPath: string, ideaId: string) => this.post('/api/ideation/ideas/get', { projectPath, ideaId }), updateIdea: (projectPath: string, ideaId: string, updates: UpdateIdeaInput) => this.post('/api/ideation/ideas/update', { projectPath, ideaId, updates }), deleteIdea: (projectPath: string, ideaId: string) => this.post('/api/ideation/ideas/delete', { projectPath, ideaId }), analyzeProject: (projectPath: string) => this.post('/api/ideation/analyze', { projectPath }), generateSuggestions: ( projectPath: string, promptId: string, category: IdeaCategory, count?: number ) => this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }), convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) => this.post('/api/ideation/convert', { projectPath, ideaId, ...options }), addSuggestionToBoard: ( projectPath: string, suggestion: AnalysisSuggestion ): Promise<{ success: boolean; featureId?: string; error?: string }> => this.post('/api/ideation/add-suggestion', { projectPath, suggestion }), getPrompts: () => this.get('/api/ideation/prompts'), onStream: (callback: (event: any) => void): (() => void) => { return this.subscribeToEvent('ideation:stream', callback as EventCallback); }, onAnalysisEvent: (callback: (event: any) => void): (() => void) => { return this.subscribeToEvent('ideation:analysis', callback as EventCallback); }, }; // Notifications API - project-level notifications notifications: NotificationsAPI & { onNotificationCreated: (callback: (notification: any) => void) => () => void; } = { list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }), getUnreadCount: (projectPath: string) => this.post('/api/notifications/unread-count', { projectPath }), markAsRead: (projectPath: string, notificationId?: string) => this.post('/api/notifications/mark-read', { projectPath, notificationId }), dismiss: (projectPath: string, notificationId?: string) => this.post('/api/notifications/dismiss', { projectPath, notificationId }), onNotificationCreated: (callback: (notification: any) => void): (() => void) => { return this.subscribeToEvent('notification:created', callback as EventCallback); }, }; // Event History API - stored events for debugging and replay eventHistory: EventHistoryAPI = { list: (projectPath: string, filter?: EventHistoryFilter) => this.post('/api/event-history/list', { projectPath, filter }), get: (projectPath: string, eventId: string) => this.post('/api/event-history/get', { projectPath, eventId }), delete: (projectPath: string, eventId: string) => this.post('/api/event-history/delete', { projectPath, eventId }), clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }), replay: (projectPath: string, eventId: string, hookIds?: string[]) => this.post('/api/event-history/replay', { projectPath, eventId, hookIds }), }; // MCP API - Test MCP server connections and list tools // SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent // drive-by command execution attacks. Servers must be saved first. mcp = { testServer: ( serverId: string ): Promise<{ success: boolean; tools?: Array<{ name: string; description?: string; inputSchema?: Record; enabled: boolean; }>; error?: string; connectionTime?: number; serverInfo?: { name?: string; version?: string; }; }> => this.post('/api/mcp/test', { serverId }), listTools: ( serverId: string ): Promise<{ success: boolean; tools?: Array<{ name: string; description?: string; inputSchema?: Record; enabled: boolean; }>; error?: string; }> => this.post('/api/mcp/tools', { serverId }), }; // Pipeline API - custom workflow pipeline steps pipeline = { getConfig: ( projectPath: string ): Promise<{ success: boolean; config?: { version: 1; steps: Array<{ id: string; name: string; order: number; instructions: string; colorClass: string; createdAt: string; updatedAt: string; }>; }; error?: string; }> => this.post('/api/pipeline/config', { projectPath }), saveConfig: ( projectPath: string, config: { version: 1; steps: Array<{ id: string; name: string; order: number; instructions: string; colorClass: string; createdAt: string; updatedAt: string; }>; } ): Promise<{ success: boolean; error?: string }> => this.post('/api/pipeline/config/save', { projectPath, config }), addStep: ( projectPath: string, step: { name: string; order: number; instructions: string; colorClass: string; } ): Promise<{ success: boolean; step?: { id: string; name: string; order: number; instructions: string; colorClass: string; createdAt: string; updatedAt: string; }; error?: string; }> => this.post('/api/pipeline/steps/add', { projectPath, step }), updateStep: ( projectPath: string, stepId: string, updates: Partial<{ name: string; order: number; instructions: string; colorClass: string; }> ): Promise<{ success: boolean; step?: { id: string; name: string; order: number; instructions: string; colorClass: string; createdAt: string; updatedAt: string; }; error?: string; }> => this.post('/api/pipeline/steps/update', { projectPath, stepId, updates }), deleteStep: ( projectPath: string, stepId: string ): Promise<{ success: boolean; error?: string }> => this.post('/api/pipeline/steps/delete', { projectPath, stepId }), reorderSteps: ( projectPath: string, stepIds: string[] ): Promise<{ success: boolean; error?: string }> => this.post('/api/pipeline/steps/reorder', { projectPath, stepIds }), }; } // Singleton instance let httpApiClientInstance: HttpApiClient | null = null; export function getHttpApiClient(): HttpApiClient { if (!httpApiClientInstance) { httpApiClientInstance = new HttpApiClient(); } return httpApiClientInstance; } // Start API key initialization immediately when this module is imported // This ensures the init promise is created early, even before React components mount // The actual async work happens in the background and won't block module loading initApiKey().catch((error) => { logger.error('Failed to initialize API key:', error); });