- 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.
1071 lines
40 KiB
TypeScript
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 () => {};
|
|
},
|
|
};
|
|
}
|