agent-ecosystem/src/renderer/api/httpClient.ts
iliya dba2d98923 feat: enhance session and subagent routes with cache bypass functionality
- Updated session and subagent route handlers to support an optional `bypassCache` query parameter, allowing clients to bypass cached responses.
- Enhanced README to include a Discord link for community engagement.
- Improved CSS for lightbox toolbar buttons to address macOS hit-testing issues.
- Refactored task ID linkification in markdown to ensure accurate matching and improved functionality in various components.
2026-03-07 00:05:38 +02:00

1071 lines
40 KiB
TypeScript

/**
* 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,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
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 { EditorAPI, ProjectAPI } from '@shared/types/editor';
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<string, Set<(...args: any[]) => 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<T>(res: Response): Promise<T> {
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<T>(path: string): Promise<T> {
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<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async post<T>(path: string, body?: unknown): Promise<T> {
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<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async del<T>(path: string, body?: unknown): Promise<T> {
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<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async put<T>(path: string, body?: unknown): Promise<T> {
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<T>(res);
} finally {
clearTimeout(timeout);
}
}
// ---------------------------------------------------------------------------
// Core session/project APIs
// ---------------------------------------------------------------------------
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
getSessions = (projectId: string): Promise<Session[]> =>
this.get<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions`);
getSessionsPaginated = (
projectId: string,
cursor: string | null,
limit?: number,
options?: SessionsPaginationOptions
): Promise<PaginatedSessionsResult> => {
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<PaginatedSessionsResult>(qs ? `${path}?${qs}` : path);
};
searchSessions = (
projectId: string,
query: string,
maxResults?: number
): Promise<SearchSessionsResult> => {
const params = new URLSearchParams({ q: query });
if (maxResults) params.set('maxResults', String(maxResults));
return this.get<SearchSessionsResult>(
`/api/projects/${encodeURIComponent(projectId)}/search?${params}`
);
};
searchAllProjects = (query: string, maxResults?: number): Promise<SearchSessionsResult> => {
const params = new URLSearchParams({ q: query });
if (maxResults) params.set('maxResults', String(maxResults));
return this.get<SearchSessionsResult>(`/api/search?${params}`);
};
getSessionDetail = (
projectId: string,
sessionId: string,
options?: { bypassCache?: boolean }
): Promise<SessionDetail | null> => {
const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString();
return this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`
);
};
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
this.get<SessionMetrics | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics`
);
getWaterfallData = (projectId: string, sessionId: string): Promise<WaterfallData | null> =>
this.get<WaterfallData | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall`
);
getSubagentDetail = (
projectId: string,
sessionId: string,
subagentId: string,
options?: { bypassCache?: boolean }
): Promise<SubagentDetail | null> => {
const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString();
return this.get<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}`
);
};
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
this.get<ConversationGroup[]>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
);
getSessionsByIds = (
projectId: string,
sessionIds: string[],
options?: SessionsByIdsOptions
): Promise<Session[]> =>
this.post<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, {
sessionIds,
metadataLevel: options?.metadataLevel,
});
// ---------------------------------------------------------------------------
// Repository grouping
// ---------------------------------------------------------------------------
getRepositoryGroups = (): Promise<RepositoryGroup[]> =>
this.get<RepositoryGroup[]>('/api/repository-groups');
getWorktreeSessions = (worktreeId: string): Promise<Session[]> =>
this.get<Session[]>(`/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<Record<string, boolean>> =>
this.post<Record<string, boolean>>('/api/validate/mentions', { mentions, projectPath });
// ---------------------------------------------------------------------------
// CLAUDE.md reading
// ---------------------------------------------------------------------------
readClaudeMdFiles = (projectRoot: string): Promise<Record<string, ClaudeMdFileInfo>> =>
this.post<Record<string, ClaudeMdFileInfo>>('/api/read-claude-md', { projectRoot });
readDirectoryClaudeMd = (dirPath: string): Promise<ClaudeMdFileInfo> =>
this.post<ClaudeMdFileInfo>('/api/read-directory-claude-md', { dirPath });
readMentionedFile = (
absolutePath: string,
projectRoot: string,
maxTokens?: number
): Promise<ClaudeMdFileInfo | null> =>
this.post<ClaudeMdFileInfo | null>('/api/read-mentioned-file', {
absolutePath,
projectRoot,
maxTokens,
});
// ---------------------------------------------------------------------------
// Agent config reading
// ---------------------------------------------------------------------------
readAgentConfigs = (projectRoot: string): Promise<Record<string, AgentConfig>> =>
this.post<Record<string, AgentConfig>>('/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<AppConfig> => {
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<AppConfig> => {
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<AppConfig> => {
await this.post('/api/config/ignore-regex', { pattern });
return this.config.get();
},
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await this.del('/api/config/ignore-regex', { pattern });
return this.config.get();
},
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await this.post('/api/config/ignore-repository', { repositoryId });
return this.config.get();
},
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await this.del('/api/config/ignore-repository', { repositoryId });
return this.config.get();
},
snooze: async (minutes: number): Promise<AppConfig> => {
await this.post('/api/config/snooze', { minutes });
return this.config.get();
},
clearSnooze: async (): Promise<AppConfig> => {
await this.post('/api/config/clear-snooze');
return this.config.get();
},
addTrigger: async (trigger): Promise<AppConfig> => {
await this.post('/api/config/triggers', trigger);
return this.config.get();
},
updateTrigger: async (triggerId: string, updates): Promise<AppConfig> => {
await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates);
return this.config.get();
},
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`);
return this.config.get();
},
getTriggers: async (): Promise<NotificationTrigger[]> => {
const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>(
'/api/config/triggers'
);
return result.data ?? [];
},
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
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<string[]> => {
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
return [];
},
selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {
console.warn('[HttpAPIClient] selectClaudeRootFolder is not available in browser mode');
return null;
},
getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {
const config = await this.config.get();
const fallbackPath = config.general.claudeRootPath ?? '~/.claude';
return {
defaultPath: fallbackPath,
resolvedPath: fallbackPath,
customPath: config.general.claudeRootPath,
};
},
findWslClaudeRoots: async (): Promise<WslClaudeRootCandidate[]> => {
console.warn('[HttpAPIClient] findWslClaudeRoots is not available in browser mode');
return [];
},
openInEditor: async (): Promise<void> => {
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
},
pinSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/pin-session', { projectId, sessionId }),
unpinSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/unpin-session', { projectId, sessionId }),
hideSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/hide-session', { projectId, sessionId }),
unhideSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/unhide-session', { projectId, sessionId }),
hideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
addCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/add-custom-project-path', { projectPath }),
removeCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/remove-custom-project-path', { projectPath }),
};
// ---------------------------------------------------------------------------
// Session navigation
// ---------------------------------------------------------------------------
session: SessionAPI = {
scrollToLine: (sessionId: string, lineNumber: number): Promise<void> =>
this.post('/api/session/scroll-to-line', { sessionId, lineNumber }),
};
// ---------------------------------------------------------------------------
// Zoom (browser fallbacks)
// ---------------------------------------------------------------------------
getZoomFactor = async (): Promise<number> => 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<void> => {
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<void> => {},
maximize: async (): Promise<void> => {},
close: async (): Promise<void> => {},
isMaximized: async (): Promise<boolean> => false,
isFullScreen: async (): Promise<boolean> => false,
relaunch: async (): Promise<void> => {},
};
onFullScreenChange =
(_callback: (isFullScreen: boolean) => void): (() => void) =>
() => {};
// ---------------------------------------------------------------------------
// Updater (browser no-ops)
// ---------------------------------------------------------------------------
updater: UpdaterAPI = {
check: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
download: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
onStatus: (_callback): (() => void) => {
return () => {};
},
};
// ---------------------------------------------------------------------------
// SSH
// ---------------------------------------------------------------------------
ssh: SshAPI = {
connect: (config: SshConnectionConfig): Promise<SshConnectionStatus> =>
this.post('/api/ssh/connect', config),
disconnect: (): Promise<SshConnectionStatus> => this.post('/api/ssh/disconnect'),
getState: (): Promise<SshConnectionStatus> => this.get('/api/ssh/state'),
test: (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> =>
this.post('/api/ssh/test', config),
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
const result = await this.get<{ success: boolean; data?: SshConfigHostEntry[] }>(
'/api/ssh/config-hosts'
);
return result.data ?? [];
},
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
const result = await this.post<{
success: boolean;
data?: SshConfigHostEntry | null;
}>('/api/ssh/resolve-host', { alias });
return result.data ?? null;
},
saveLastConnection: (config: SshLastConnection): Promise<void> =>
this.post('/api/ssh/save-last-connection', config),
getLastConnection: async (): Promise<SshLastConnection | null> => {
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<ContextInfo[]> => this.get<ContextInfo[]>('/api/contexts'),
getActive: (): Promise<string> => this.get<string>('/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<HttpServerStatus> =>
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
stop: (): Promise<HttpServerStatus> => {
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<HttpServerStatus> =>
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
};
teams: TeamsAPI = {
list: async (): Promise<TeamSummary[]> => {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode');
},
getClaudeLogs: async (
_teamName: string,
_query?: TeamClaudeLogsQuery
): Promise<TeamClaudeLogsResponse> => {
console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode');
return { lines: [], total: 0, hasMore: false };
},
deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode');
},
restoreTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team restore is not available in browser mode');
},
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Permanent team deletion is not available in browser mode');
},
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
throw new Error('Team provisioning is not available in browser mode');
},
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
throw new Error('Team provisioning is not available in browser mode');
},
launchTeam: async (_request: TeamLaunchRequest): Promise<TeamLaunchResponse> => {
throw new Error('Team launch is not available in browser mode');
},
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
throw new Error('Team provisioning is not available in browser mode');
},
cancelProvisioning: async (_runId: string): Promise<void> => {
throw new Error('Team provisioning is not available in browser mode');
},
sendMessage: async (
_teamName: string,
_request: SendMessageRequest
): Promise<SendMessageResult> => {
throw new Error('Team messaging is not available in browser mode');
},
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
throw new Error('Team task creation is not available in browser mode');
},
requestReview: async (_teamName: string, _taskId: string): Promise<void> => {
throw new Error('Team review is not available in browser mode');
},
updateKanban: async (
_teamName: string,
_taskId: string,
_patch: UpdateKanbanPatch
): Promise<void> => {
throw new Error('Team kanban is not available in browser mode');
},
updateKanbanColumnOrder: async (
_teamName: string,
_columnId: KanbanColumnId,
_orderedTaskIds: string[]
): Promise<void> => {
throw new Error('Team kanban column order is not available in browser mode');
},
updateTaskStatus: async (
_teamName: string,
_taskId: string,
_status: TeamTaskStatus
): Promise<void> => {
throw new Error('Team task status update is not available in browser mode');
},
updateTaskOwner: async (
_teamName: string,
_taskId: string,
_owner: string | null
): Promise<void> => {
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<void> => {
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<void> => {
throw new Error('Team process communication is not available in browser mode');
},
processAlive: async (_teamName: string): Promise<boolean> => {
return false;
},
aliveList: async (): Promise<string[]> => {
return [];
},
stop: async (): Promise<void> => {
throw new Error('Team stop is not available in browser mode');
},
createConfig: async (): Promise<void> => {
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<GlobalTask[]> => {
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<void> => {
throw new Error('Team member management is not available in browser mode');
},
replaceMembers: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
removeMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
updateMemberRole: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
return null;
},
getAttachments: async (
_teamName: string,
_messageId: string
): Promise<AttachmentFileData[]> => {
return [];
},
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
// Not available via HTTP client — no-op
},
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
return 'offline';
},
getLeadContext: async () => {
return null;
},
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},
restoreTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},
getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => {
return [];
},
setTaskClarification: async (
_teamName: string,
_taskId: string,
_value: 'lead' | 'user' | null
): Promise<void> => {
// Not available via HTTP client — no-op
},
showMessageNotification: async (): Promise<void> => {
// Not available via HTTP client — native notifications require Electron
},
addTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
throw new Error('Task relationships are not available in browser mode');
},
removeTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
throw new Error('Task relationships are not available in browser mode');
},
saveTaskAttachment: async (
_teamName: string,
_taskId: string,
_attachmentId: string,
_filename: string,
_mimeType: string,
_base64Data: string
): Promise<never> => {
throw new Error('Task attachments are not available in browser mode');
},
getTaskAttachment: async (
_teamName: string,
_taskId: string,
_attachmentId: string,
_mimeType: string
): Promise<string | null> => {
return null;
},
deleteTaskAttachment: async (
_teamName: string,
_taskId: string,
_attachmentId: string,
_mimeType: string
): Promise<void> => {
throw new Error('Task attachments are not available in browser mode');
},
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 () => {};
},
respondToToolApproval: async (): Promise<void> => {
throw new Error('Tool approval not available in browser mode');
},
onToolApprovalEvent: (): (() => void) => {
return () => {};
},
};
// Review API stubs
review = {
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getTaskChanges: async (_teamName: string, _taskId: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getFileContent: async (
_teamName: string,
_memberName: string | undefined,
_filePath: string,
_snippets: SnippetDiff[] = []
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
applyDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 2 stubs
checkConflict: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectHunks: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
rejectFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
previewReject: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Editable diff stubs
saveEditedFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Decision persistence stubs
loadDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
saveDecisions: async (
_teamName: string,
_scopeKey: string,
_hunkDecisions: Record<string, unknown>,
_fileDecisions: Record<string, unknown>,
_hunkContextHashesByFile?: Record<string, Record<number, string>>
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
clearDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
// Phase 4 stubs
getGitFileLog: async (): Promise<never> => {
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<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
},
onProgress: (): (() => void) => {
return () => {};
},
};
// ---------------------------------------------------------------------------
// Terminal (not available in browser mode)
// ---------------------------------------------------------------------------
terminal: TerminalAPI = {
spawn: async (): Promise<string> => {
throw new Error('Terminal not available in browser mode');
},
write: () => {},
resize: () => {},
kill: () => {},
onData: (): (() => void) => () => {},
onExit: (): (() => void) => () => {},
};
// ---------------------------------------------------------------------------
// Project (not available in browser mode)
// ---------------------------------------------------------------------------
project: ProjectAPI = {
listFiles: async () => {
throw new Error('Project API not available in browser mode');
},
};
// ---------------------------------------------------------------------------
// Editor (not available in browser mode)
// ---------------------------------------------------------------------------
editor: EditorAPI = {
open: async () => {
throw new Error('Editor not available in browser mode');
},
close: async () => {
throw new Error('Editor not available in browser mode');
},
readDir: async () => {
throw new Error('Editor not available in browser mode');
},
readFile: async () => {
throw new Error('Editor not available in browser mode');
},
writeFile: async () => {
throw new Error('Editor not available in browser mode');
},
createFile: async () => {
throw new Error('Editor not available in browser mode');
},
createDir: async () => {
throw new Error('Editor not available in browser mode');
},
deleteFile: async () => {
throw new Error('Editor not available in browser mode');
},
moveFile: async () => {
throw new Error('Editor not available in browser mode');
},
renameFile: async () => {
throw new Error('Editor not available in browser mode');
},
searchInFiles: async () => {
throw new Error('Editor not available in browser mode');
},
listFiles: async () => {
throw new Error('Editor not available in browser mode');
},
readBinaryPreview: async () => {
throw new Error('Editor not available in browser mode');
},
gitStatus: async () => {
throw new Error('Editor not available in browser mode');
},
watchDir: async () => {
throw new Error('Editor not available in browser mode');
},
setWatchedFiles: async () => {
throw new Error('Editor not available in browser mode');
},
setWatchedDirs: async () => {
throw new Error('Editor not available in browser mode');
},
onEditorChange: () => {
return () => {};
},
};
}