/** * HTTP-based implementation of ElectronAPI for browser mode. * * Replaces Electron IPC with fetch() for request/response and * EventSource (SSE) for real-time events. Allows the renderer * to run in a regular browser connected to an HTTP server. */ import type { AppConfig, AttachmentFileData, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, CliInstallerAPI, ConfigAPI, ContextInfo, ConversationGroup, CreateTaskRequest, ElectronAPI, FileChangeEvent, GlobalTask, HttpServerAPI, HttpServerStatus, KanbanColumnId, NotificationsAPI, NotificationTrigger, PaginatedSessionsResult, Project, RepositoryGroup, SearchSessionsResult, SendMessageRequest, SendMessageResult, Session, SessionAPI, SessionDetail, SessionMetrics, SessionsByIdsOptions, SessionsPaginationOptions, SnippetDiff, SshAPI, SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, SshLastConnection, SubagentDetail, TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, TriggerTestResult, UpdateKanbanPatch, UpdaterAPI, WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; import type { AgentConfig } from '@shared/types/api'; import type { TerminalAPI } from '@shared/types/terminal'; export class HttpAPIClient implements ElectronAPI { private baseUrl: string; private eventSource: EventSource | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures private eventListeners = new Map void>>(); constructor(baseUrl: string) { this.baseUrl = baseUrl; this.initEventSource(); } // --------------------------------------------------------------------------- // SSE event infrastructure // --------------------------------------------------------------------------- private initEventSource(): void { this.eventSource = new EventSource(`${this.baseUrl}/api/events`); this.eventSource.onopen = () => console.log('[HttpAPIClient] SSE connected'); this.eventSource.onerror = () => { // Auto-reconnect is built into EventSource console.warn('[HttpAPIClient] SSE connection error, will reconnect...'); }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures private addEventListener(channel: string, callback: (...args: any[]) => void): () => void { if (!this.eventListeners.has(channel)) { this.eventListeners.set(channel, new Set()); // Register SSE listener for this channel once this.eventSource?.addEventListener(channel, ((event: MessageEvent) => { const data: unknown = JSON.parse(event.data as string); const listeners = this.eventListeners.get(channel); listeners?.forEach((cb) => cb(data)); }) as EventListener); } this.eventListeners.get(channel)!.add(callback); return () => { this.eventListeners.get(channel)?.delete(callback); }; } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- /** * JSON reviver that converts ISO 8601 date strings back to Date objects. * Electron IPC preserves Date instances via structured clone, but HTTP JSON * serialization turns them into strings. This restores them so that * `.getTime()` and other Date methods work in the renderer. */ // eslint-disable-next-line security/detect-unsafe-regex -- anchored pattern with bounded quantifier; no backtracking risk private static readonly ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z?$/; private static reviveDates(_key: string, value: unknown): unknown { if (typeof value === 'string' && HttpAPIClient.ISO_DATE_RE.test(value)) { const d = new Date(value); if (!isNaN(d.getTime())) return d; } return value; } private async parseJson(res: Response): Promise { const text = await res.text(); if (!res.ok) { const parsed = JSON.parse(text) as { error?: string }; throw new Error(parsed.error ?? `HTTP ${res.status}`); } return JSON.parse(text, (key, value) => HttpAPIClient.reviveDates(key, value)) as T; } private async get(path: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(`${this.baseUrl}${path}`, { signal: controller.signal }); return this.parseJson(res); } finally { clearTimeout(timeout); } } private async post(path: string, body?: unknown): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); return this.parseJson(res); } finally { clearTimeout(timeout); } } private async del(path: string, body?: unknown): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(`${this.baseUrl}${path}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); return this.parseJson(res); } finally { clearTimeout(timeout); } } private async put(path: string, body?: unknown): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(`${this.baseUrl}${path}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); return this.parseJson(res); } finally { clearTimeout(timeout); } } // --------------------------------------------------------------------------- // Core session/project APIs // --------------------------------------------------------------------------- getAppVersion = (): Promise => this.get('/api/version'); getProjects = (): Promise => this.get('/api/projects'); getSessions = (projectId: string): Promise => this.get(`/api/projects/${encodeURIComponent(projectId)}/sessions`); getSessionsPaginated = ( projectId: string, cursor: string | null, limit?: number, options?: SessionsPaginationOptions ): Promise => { const params = new URLSearchParams(); if (cursor) params.set('cursor', cursor); if (limit) params.set('limit', String(limit)); if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false'); if (options?.prefilterAll === false) params.set('prefilterAll', 'false'); if (options?.metadataLevel) params.set('metadataLevel', options.metadataLevel); const qs = params.toString(); const encodedId = encodeURIComponent(projectId); const path = `/api/projects/${encodedId}/sessions-paginated`; return this.get(qs ? `${path}?${qs}` : path); }; searchSessions = ( projectId: string, query: string, maxResults?: number ): Promise => { const params = new URLSearchParams({ q: query }); if (maxResults) params.set('maxResults', String(maxResults)); return this.get( `/api/projects/${encodeURIComponent(projectId)}/search?${params}` ); }; searchAllProjects = (query: string, maxResults?: number): Promise => { const params = new URLSearchParams({ q: query }); if (maxResults) params.set('maxResults', String(maxResults)); return this.get(`/api/search?${params}`); }; getSessionDetail = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` ); getSessionMetrics = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics` ); getWaterfallData = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall` ); getSubagentDetail = ( projectId: string, sessionId: string, subagentId: string ): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` ); getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` ); getSessionsByIds = ( projectId: string, sessionIds: string[], options?: SessionsByIdsOptions ): Promise => this.post(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, { sessionIds, metadataLevel: options?.metadataLevel, }); // --------------------------------------------------------------------------- // Repository grouping // --------------------------------------------------------------------------- getRepositoryGroups = (): Promise => this.get('/api/repository-groups'); getWorktreeSessions = (worktreeId: string): Promise => this.get(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`); // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- validatePath = ( relativePath: string, projectPath: string ): Promise<{ exists: boolean; isDirectory?: boolean }> => this.post<{ exists: boolean; isDirectory?: boolean }>('/api/validate/path', { relativePath, projectPath, }); validateMentions = ( mentions: { type: 'path'; value: string }[], projectPath: string ): Promise> => this.post>('/api/validate/mentions', { mentions, projectPath }); // --------------------------------------------------------------------------- // CLAUDE.md reading // --------------------------------------------------------------------------- readClaudeMdFiles = (projectRoot: string): Promise> => this.post>('/api/read-claude-md', { projectRoot }); readDirectoryClaudeMd = (dirPath: string): Promise => this.post('/api/read-directory-claude-md', { dirPath }); readMentionedFile = ( absolutePath: string, projectRoot: string, maxTokens?: number ): Promise => this.post('/api/read-mentioned-file', { absolutePath, projectRoot, maxTokens, }); // --------------------------------------------------------------------------- // Agent config reading // --------------------------------------------------------------------------- readAgentConfigs = (projectRoot: string): Promise> => this.post>('/api/read-agent-configs', { projectRoot }); // --------------------------------------------------------------------------- // Notifications (nested API) // --------------------------------------------------------------------------- notifications: NotificationsAPI = { get: (options) => this.get( `/api/notifications?${new URLSearchParams( options ? { limit: String(options.limit ?? 20), offset: String(options.offset ?? 0), } : {} )}` ), markRead: (id) => this.post(`/api/notifications/${encodeURIComponent(id)}/read`), markAllRead: () => this.post('/api/notifications/read-all'), delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`), clear: () => this.del('/api/notifications'), getUnreadCount: () => this.get('/api/notifications/unread-count'), // IPC signature: (event: unknown, error: unknown) => void onNew: (callback) => this.addEventListener('notification:new', (data: unknown) => callback(null, data)), // IPC signature: (event: unknown, payload: { total; unreadCount }) => void onUpdated: (callback) => this.addEventListener('notification:updated', (data: unknown) => callback(null, data as { total: number; unreadCount: number }) ), // IPC signature: (event: unknown, data: unknown) => void onClicked: (callback) => this.addEventListener('notification:clicked', (data: unknown) => callback(null, data)), }; // --------------------------------------------------------------------------- // Config (nested API) // --------------------------------------------------------------------------- config: ConfigAPI = { get: async (): Promise => { const result = await this.get<{ success: boolean; data?: AppConfig; error?: string }>( '/api/config' ); if (!result.success) throw new Error(result.error ?? 'Failed to get config'); return result.data!; }, update: async (section: string, data: object): Promise => { const result = await this.post<{ success: boolean; data?: AppConfig; error?: string }>( '/api/config/update', { section, data } ); if (!result.success) throw new Error(result.error ?? 'Failed to update config'); return result.data!; }, addIgnoreRegex: async (pattern: string): Promise => { await this.post('/api/config/ignore-regex', { pattern }); return this.config.get(); }, removeIgnoreRegex: async (pattern: string): Promise => { await this.del('/api/config/ignore-regex', { pattern }); return this.config.get(); }, addIgnoreRepository: async (repositoryId: string): Promise => { await this.post('/api/config/ignore-repository', { repositoryId }); return this.config.get(); }, removeIgnoreRepository: async (repositoryId: string): Promise => { await this.del('/api/config/ignore-repository', { repositoryId }); return this.config.get(); }, snooze: async (minutes: number): Promise => { await this.post('/api/config/snooze', { minutes }); return this.config.get(); }, clearSnooze: async (): Promise => { await this.post('/api/config/clear-snooze'); return this.config.get(); }, addTrigger: async (trigger): Promise => { await this.post('/api/config/triggers', trigger); return this.config.get(); }, updateTrigger: async (triggerId: string, updates): Promise => { await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates); return this.config.get(); }, removeTrigger: async (triggerId: string): Promise => { await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`); return this.config.get(); }, getTriggers: async (): Promise => { const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>( '/api/config/triggers' ); return result.data ?? []; }, testTrigger: async (trigger: NotificationTrigger): Promise => { const result = await this.post<{ success: boolean; data?: TriggerTestResult; error?: string; }>(`/api/config/triggers/${encodeURIComponent(trigger.id)}/test`, trigger); if (!result.success) throw new Error(result.error ?? 'Failed to test trigger'); return result.data!; }, selectFolders: async (): Promise => { console.warn('[HttpAPIClient] selectFolders is not available in browser mode'); return []; }, selectClaudeRootFolder: async (): Promise => { console.warn('[HttpAPIClient] selectClaudeRootFolder is not available in browser mode'); return null; }, getClaudeRootInfo: async (): Promise => { const config = await this.config.get(); const fallbackPath = config.general.claudeRootPath ?? '~/.claude'; return { defaultPath: fallbackPath, resolvedPath: fallbackPath, customPath: config.general.claudeRootPath, }; }, findWslClaudeRoots: async (): Promise => { console.warn('[HttpAPIClient] findWslClaudeRoots is not available in browser mode'); return []; }, openInEditor: async (): Promise => { console.warn('[HttpAPIClient] openInEditor is not available in browser mode'); }, pinSession: (projectId: string, sessionId: string): Promise => this.post('/api/config/pin-session', { projectId, sessionId }), unpinSession: (projectId: string, sessionId: string): Promise => this.post('/api/config/unpin-session', { projectId, sessionId }), hideSession: (projectId: string, sessionId: string): Promise => this.post('/api/config/hide-session', { projectId, sessionId }), unhideSession: (projectId: string, sessionId: string): Promise => this.post('/api/config/unhide-session', { projectId, sessionId }), hideSessions: (projectId: string, sessionIds: string[]): Promise => this.post('/api/config/hide-sessions', { projectId, sessionIds }), unhideSessions: (projectId: string, sessionIds: string[]): Promise => this.post('/api/config/unhide-sessions', { projectId, sessionIds }), }; // --------------------------------------------------------------------------- // Session navigation // --------------------------------------------------------------------------- session: SessionAPI = { scrollToLine: (sessionId: string, lineNumber: number): Promise => this.post('/api/session/scroll-to-line', { sessionId, lineNumber }), }; // --------------------------------------------------------------------------- // Zoom (browser fallbacks) // --------------------------------------------------------------------------- getZoomFactor = async (): Promise => 1.0; onZoomFactorChanged = (_callback: (zoomFactor: number) => void): (() => void) => { // No-op in browser mode — zoom is managed by the browser itself return () => {}; }; // --------------------------------------------------------------------------- // File change events (via SSE) // --------------------------------------------------------------------------- onFileChange = (callback: (event: FileChangeEvent) => void): (() => void) => this.addEventListener('file-change', callback); onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) => this.addEventListener('todo-change', callback); // --------------------------------------------------------------------------- // Shell operations (browser fallbacks) // --------------------------------------------------------------------------- openPath = async ( _targetPath: string, _projectRoot?: string ): Promise<{ success: boolean; error?: string }> => { console.warn('[HttpAPIClient] openPath is not available in browser mode'); return { success: false, error: 'Not available in browser mode' }; }; showInFolder = async (_filePath: string): Promise => { console.warn('[HttpAPIClient] showInFolder is not available in browser mode'); }; openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => { window.open(url, '_blank'); return { success: true }; }; windowControls = { minimize: async (): Promise => {}, maximize: async (): Promise => {}, close: async (): Promise => {}, isMaximized: async (): Promise => false, isFullScreen: async (): Promise => false, relaunch: async (): Promise => {}, }; onFullScreenChange = (_callback: (isFullScreen: boolean) => void): (() => void) => () => {}; // --------------------------------------------------------------------------- // Updater (browser no-ops) // --------------------------------------------------------------------------- updater: UpdaterAPI = { check: async (): Promise => { console.warn('[HttpAPIClient] updater not available in browser mode'); }, download: async (): Promise => { console.warn('[HttpAPIClient] updater not available in browser mode'); }, install: async (): Promise => { console.warn('[HttpAPIClient] updater not available in browser mode'); }, onStatus: (_callback): (() => void) => { return () => {}; }, }; // --------------------------------------------------------------------------- // SSH // --------------------------------------------------------------------------- ssh: SshAPI = { connect: (config: SshConnectionConfig): Promise => this.post('/api/ssh/connect', config), disconnect: (): Promise => this.post('/api/ssh/disconnect'), getState: (): Promise => this.get('/api/ssh/state'), test: (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => this.post('/api/ssh/test', config), getConfigHosts: async (): Promise => { const result = await this.get<{ success: boolean; data?: SshConfigHostEntry[] }>( '/api/ssh/config-hosts' ); return result.data ?? []; }, resolveHost: async (alias: string): Promise => { const result = await this.post<{ success: boolean; data?: SshConfigHostEntry | null; }>('/api/ssh/resolve-host', { alias }); return result.data ?? null; }, saveLastConnection: (config: SshLastConnection): Promise => this.post('/api/ssh/save-last-connection', config), getLastConnection: async (): Promise => { const result = await this.get<{ success: boolean; data?: SshLastConnection | null }>( '/api/ssh/last-connection' ); return result.data ?? null; }, // IPC signature: (event: unknown, status: SshConnectionStatus) => void onStatus: (callback): (() => void) => this.addEventListener('ssh:status', (data: unknown) => callback(null, data as SshConnectionStatus) ), }; // --------------------------------------------------------------------------- // Context API // --------------------------------------------------------------------------- context = { list: (): Promise => this.get('/api/contexts'), getActive: (): Promise => this.get('/api/contexts/active'), switch: (contextId: string): Promise<{ contextId: string }> => this.post<{ contextId: string }>('/api/contexts/switch', { contextId }), onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) => this.addEventListener('context:changed', (data: unknown) => callback(null, data as ContextInfo) ), }; // HTTP Server API — in browser mode, server is already running (we're using it) httpServer: HttpServerAPI = { start: (): Promise => Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }), stop: (): Promise => { console.warn('[HttpAPIClient] Cannot stop HTTP server from browser mode'); return Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }); }, getStatus: (): Promise => Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }), }; teams: TeamsAPI = { list: async (): Promise => { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, deleteTeam: async (_teamName: string): Promise => { throw new Error('Team deletion is not available in browser mode'); }, restoreTeam: async (_teamName: string): Promise => { throw new Error('Team restore is not available in browser mode'); }, permanentlyDeleteTeam: async (_teamName: string): Promise => { throw new Error('Permanent team deletion is not available in browser mode'); }, prepareProvisioning: async (_cwd?: string): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, createTeam: async (_request: TeamCreateRequest): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, launchTeam: async (_request: TeamLaunchRequest): Promise => { throw new Error('Team launch is not available in browser mode'); }, getProvisioningStatus: async (_runId: string): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, cancelProvisioning: async (_runId: string): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, sendMessage: async ( _teamName: string, _request: SendMessageRequest ): Promise => { throw new Error('Team messaging is not available in browser mode'); }, createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => { throw new Error('Team task creation is not available in browser mode'); }, requestReview: async (_teamName: string, _taskId: string): Promise => { throw new Error('Team review is not available in browser mode'); }, updateKanban: async ( _teamName: string, _taskId: string, _patch: UpdateKanbanPatch ): Promise => { throw new Error('Team kanban is not available in browser mode'); }, updateKanbanColumnOrder: async ( _teamName: string, _columnId: KanbanColumnId, _orderedTaskIds: string[] ): Promise => { throw new Error('Team kanban column order is not available in browser mode'); }, updateTaskStatus: async ( _teamName: string, _taskId: string, _status: TeamTaskStatus ): Promise => { throw new Error('Team task status update is not available in browser mode'); }, updateTaskOwner: async ( _teamName: string, _taskId: string, _owner: string | null ): Promise => { throw new Error('Team task owner update is not available in browser mode'); }, updateTaskFields: async ( _teamName: string, _taskId: string, _fields: { subject?: string; description?: string } ): Promise => { throw new Error('Team task fields update is not available in browser mode'); }, startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => { throw new Error('Team start task is not available in browser mode'); }, processSend: async (_teamName: string, _message: string): Promise => { throw new Error('Team process communication is not available in browser mode'); }, processAlive: async (_teamName: string): Promise => { return false; }, aliveList: async (): Promise => { return []; }, stop: async (): Promise => { throw new Error('Team stop is not available in browser mode'); }, createConfig: async (): Promise => { throw new Error('Team config creation is not available in browser mode'); }, getMemberLogs: async () => { console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode'); return []; }, getLogsForTask: async () => { return []; }, getMemberStats: async () => { console.warn('[HttpAPIClient] getMemberStats is not available in browser mode'); return { linesAdded: 0, linesRemoved: 0, filesTouched: [], fileStats: {}, toolUsage: {}, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0, tasksCompleted: 0, messageCount: 0, totalDurationMs: 0, sessionCount: 0, computedAt: new Date().toISOString(), }; }, getAllTasks: async (): Promise => { console.warn('[HttpAPIClient] getAllTasks is not available in browser mode'); return []; }, updateConfig: async () => { throw new Error('Team config update is not available in browser mode'); }, addTaskComment: async () => { throw new Error('Task comments are not available in browser mode'); }, addMember: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, removeMember: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, updateMemberRole: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, getProjectBranch: async (_projectPath: string): Promise => { return null; }, getAttachments: async ( _teamName: string, _messageId: string ): Promise => { return []; }, killProcess: async (_teamName: string, _pid: number): Promise => { // Not available via HTTP client — no-op }, getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => { return 'offline'; }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, restoreTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, getDeletedTasks: async (_teamName: string): Promise => { return []; }, setTaskClarification: async ( _teamName: string, _taskId: string, _value: 'lead' | 'user' | null ): Promise => { // Not available via HTTP client — no-op }, showMessageNotification: async (): Promise => { // Not available via HTTP client — native notifications require Electron }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) ); }, onProvisioningProgress: ( _callback: (event: unknown, data: TeamProvisioningProgress) => void ): (() => void) => { return () => {}; }, }; // Review API stubs review = { getAgentChanges: async (_teamName: string, _memberName: string): Promise => { throw new Error('Review is not available in browser mode'); }, getTaskChanges: async (_teamName: string, _taskId: string): Promise => { throw new Error('Review is not available in browser mode'); }, getChangeStats: async (_teamName: string, _memberName: string): Promise => { throw new Error('Review is not available in browser mode'); }, getFileContent: async ( _teamName: string, _memberName: string | undefined, _filePath: string, _snippets: SnippetDiff[] = [] ): Promise => { throw new Error('Review is not available in browser mode'); }, applyDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Phase 2 stubs checkConflict: async (): Promise => { throw new Error('Review is not available in browser mode'); }, rejectHunks: async (): Promise => { throw new Error('Review is not available in browser mode'); }, rejectFile: async (): Promise => { throw new Error('Review is not available in browser mode'); }, previewReject: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Editable diff stubs saveEditedFile: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Decision persistence stubs loadDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); }, saveDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); }, clearDecisions: async (): Promise => { throw new Error('Review is not available in browser mode'); }, // Phase 4 stubs getGitFileLog: async (): Promise => { throw new Error('Review is not available in browser mode'); }, }; // --------------------------------------------------------------------------- // CLI Installer (not available in browser mode) // --------------------------------------------------------------------------- cliInstaller: CliInstallerAPI = { getStatus: async () => ({ installed: false, installedVersion: null, binaryPath: null, latestVersion: null, updateAvailable: false, authLoggedIn: false, authMethod: null, }), install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, onProgress: (): (() => void) => { return () => {}; }, }; // --------------------------------------------------------------------------- // Terminal (not available in browser mode) // --------------------------------------------------------------------------- terminal: TerminalAPI = { spawn: async (): Promise => { throw new Error('Terminal not available in browser mode'); }, write: () => {}, resize: () => {}, kill: () => {}, onData: (): (() => void) => () => {}, onExit: (): (() => void) => () => {}, }; }