import type { Project, AvailableProject, Session, Chat, Message, ModelInfo, SidebarResponse, ListDirResult, ViewFileResult, AgentsResponse, GitMeta, Skill, AskUserAnswer, ToolCostStat, ProviderSnapshotEntry, CoderSendMessageBody, CoderSendMessageResponse, CoderMessageWire, CoderTaskDetail, PermissionPrompt, AgentCommand, } from './types'; export class ApiError extends Error { constructor( public status: number, public body: unknown ) { super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`); } } async function request( path: string, init: RequestInit = {} ): Promise { const res = await fetch(path, { ...init, headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}), }, }); if (res.status === 204) return undefined as T; const text = await res.text(); const data = text ? JSON.parse(text) : undefined; if (!res.ok) throw new ApiError(res.status, data); return data as T; } export const api = { health: () => request<{ status: string; db: boolean }>('/api/health'), projects: { list: (params?: { status?: 'open' | 'archived' }) => request(`/api/projects${params?.status ? `?status=${params.status}` : ''}`), available: () => request('/api/projects/available'), add: (body: { path: string; name?: string }) => request('/api/projects', { method: 'POST', body: JSON.stringify(body), }), update: ( id: string, body: Partial>, ) => request(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body), }), get: (id: string) => request(`/api/projects/${id}`), archive: (id: string) => request(`/api/projects/${id}/archive`, { method: 'POST' }), unarchive: (id: string) => request(`/api/projects/${id}/unarchive`, { method: 'POST' }), // v1.9: bulk-archive every open session in this project. Server publishes // one session_archived frame per affected id, so the sidebar reducer // updates incrementally rather than waiting for a refetch. archiveAllSessions: (id: string) => request<{ archived: number; ids: string[] }>( `/api/projects/${id}/sessions/archive-all`, { method: 'POST' }, ), openSessionsCount: (id: string) => request<{ count: number }>(`/api/projects/${id}/sessions/open-count`), create: (body: { name: string; commit_message?: string; visibility?: 'private' | 'public'; create_gitea_remote?: boolean; }) => request<{ project: Project; bootstrap: { folder_created: boolean; git_initialized: boolean; first_commit: boolean; gitea_remote_created: boolean; gitea_pushed: boolean; warnings: string[]; }; }>(`/api/projects/create`, { method: 'POST', body: JSON.stringify(body), }), remove: (id: string) => request(`/api/projects/${id}`, { method: 'DELETE' }), listDir: (id: string, path: string) => request(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`), viewFile: (id: string, path: string) => request(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`), files: (id: string) => request<{ files: string[] }>(`/api/projects/${id}/files`), git: (id: string) => request(`/api/projects/${id}/git`), }, sessions: { listForProject: (projectId: string, status?: 'open' | 'archived') => request(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`), create: ( projectId: string, body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null } ) => request(`/api/projects/${projectId}/sessions`, { method: 'POST', body: JSON.stringify(body), }), get: (id: string) => request(`/api/sessions/${id}`), update: ( id: string, body: Partial< Pick< Session, | 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled' // v1.13.17: revocation path — frontend sends the shortened list // when the user removes a grant. Grants are appended only via the // separate grantReadAccess endpoint below. | 'allowed_read_paths' > > ) => request(`/api/sessions/${id}`, { method: 'PATCH', body: JSON.stringify(body), }), remove: (id: string) => request(`/api/sessions/${id}`, { method: 'DELETE' }), archive: (id: string) => request(`/api/sessions/${id}/archive`, { method: 'POST' }), unarchive: (id: string) => request(`/api/sessions/${id}/unarchive`, { method: 'POST' }), // v1.9: bulk-archive every open chat in this session. Same pattern as // archiveAllSessions — server publishes one chat_archived per id. archiveAllChats: (id: string) => request<{ archived: number; ids: string[] }>( `/api/sessions/${id}/chats/archive-all`, { method: 'POST' }, ), openChatsCount: (id: string) => request<{ count: number }>(`/api/sessions/${id}/chats/open-count`), updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) => request(`/api/sessions/${id}/workspace`, { method: 'PATCH', body: JSON.stringify({ workspace_panes: panes }), }), }, chats: { listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) => request( `/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}` ), create: (sessionId: string, body?: { name?: string }) => request(`/api/sessions/${sessionId}/chats`, { method: 'POST', body: JSON.stringify(body ?? {}), }), update: (chatId: string, body: { name: string }) => request(`/api/chats/${chatId}`, { method: 'PATCH', body: JSON.stringify(body), }), archive: (chatId: string) => request(`/api/chats/${chatId}/archive`, { method: 'POST' }), unarchive: (chatId: string) => request(`/api/chats/${chatId}/unarchive`, { method: 'POST' }), remove: (chatId: string) => request(`/api/chats/${chatId}`, { method: 'DELETE' }), messages: (chatId: string) => request(`/api/chats/${chatId}/messages`), // v1.11: anchored-rolling compaction. POST awaits the LLM call inside // the route's lifecycle; the new summary row arrives via the 'compacted' // WS frame (useSessionStream refetches + toasts). compact: (chatId: string) => request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }), stop: (chatId: string) => request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }), discardStale: (chatId: string, messageId: string) => request(`/api/chats/${chatId}/discard_stale`, { method: 'POST', body: JSON.stringify({ message_id: messageId }), }), forceSend: (chatId: string, content: string) => request<{ user_message_id: string; assistant_message_id: string }>( `/api/chats/${chatId}/force_send`, { method: 'POST', body: JSON.stringify({ content }) } ), // v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId` // is the cap-hit sentinel message the user clicked Continue on. continue: (chatId: string, sentinelMessageId: string) => request<{ assistant_message_id: string }>( `/api/chats/${chatId}/continue`, { method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) } ), fork: (chatId: string, body: { messageId: string; name?: string }) => request(`/api/chats/${chatId}/fork`, { method: 'POST', body: JSON.stringify({ message_id: body.messageId, name: body.name }), }), // Batch 9.6: slash-command invocation. Server loads the skill body // authoritatively (client doesn't get to forge file contents), persists // a synthetic skill_use tool_use + tool_result + user message + streaming // assistant, and enqueues inference. Returns all 4 new message IDs. skillInvoke: (chatId: string, skillName: string, userMessage: string | null) => request<{ synth_assistant_id: string; tool_message_id: string; user_message_id: string; assistant_message_id: string; }>(`/api/chats/${chatId}/skill_invoke`, { method: 'POST', body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), }), // Batch 9.7: submit answers for a paused ask_user_input call. Server // validates against the question shape, UPDATEs the pending tool row, // publishes the deferred tool_result frame, and enqueues the next turn. answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) => request<{ tool_message_id: string; assistant_message_id: string }>( `/api/chats/${chatId}/answer_user_input`, { method: 'POST', body: JSON.stringify({ tool_call_id: toolCallId, answers }), }, ), // v1.13.17-cross-repo-reads: resume a paused request_read_access. On // 'allow' the server re-resolves the grant root and appends it to // sessions.allowed_read_paths; the returned list reflects the post- // grant state. On 'deny' the array is unchanged. grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') => request<{ tool_message_id: string; assistant_message_id: string; allowed_read_paths: string[]; }>(`/api/chats/${chatId}/grant_read_access`, { method: 'POST', body: JSON.stringify({ tool_call_id: toolCallId, decision }), }), }, messages: { list: (sessionId: string) => request(`/api/sessions/${sessionId}/messages`), send: (chatId: string, content: string) => request<{ user_message_id: string; assistant_message_id: string }>( `/api/chats/${chatId}/messages`, { method: 'POST', body: JSON.stringify({ content }), } ), regenerate: (chatId: string, messageId: string) => request<{ assistant_message_id: string }>( `/api/chats/${chatId}/messages/${messageId}/regenerate`, { method: 'POST' } ), remove: (chatId: string, messageId: string) => request(`/api/chats/${chatId}/messages/${messageId}`, { method: 'DELETE', }), // v1.14.x-html-artifact-panes: write the artifact to // /.boocode/artifacts/-. and return the // path + a /api/projects/.../artifacts/ URL the browser can // GET to download. fmt=html requires the assistant message to carry an // html_artifact part (404 otherwise). downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') => request<{ path: string; url: string }>( `/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`, { method: 'POST' }, ), // v1.14.x-html-artifact-panes: fetch the html_artifact part payload so // HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact // part on this message; MessageBubble uses that as a signal to fall back // to the markdown pane variant. getHtmlArtifact: (chatId: string, messageId: string) => request<{ html_content: string; char_count: number; title: string }>( `/api/chats/${chatId}/messages/${messageId}/html_artifact`, ), }, models: () => request('/api/models'), coder: { snapshot: (cwd?: string) => { const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''; return request(`/api/coder/providers/snapshot${qs}`); }, refreshProviders: () => request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }), sendMessage: (sessionId: string, body: CoderSendMessageBody) => request(`/api/coder/sessions/${sessionId}/messages`, { method: 'POST', body: JSON.stringify(body), }), getTaskPermission: (taskId: string) => request(`/api/coder/tasks/${taskId}/permission`), respondTaskPermission: (taskId: string, optionId: string | null) => request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, { method: 'POST', body: JSON.stringify({ option_id: optionId }), }), getTaskCommands: (taskId: string) => request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`), getTask: (taskId: string) => request(`/api/coder/tasks/${taskId}`), listMessages: (sessionId: string, chatId?: string) => request( `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, ), skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) => request<{ user_message_id: string; assistant_message_id: string; synth_assistant_id: string; tool_message_id: string; }>(`/api/coder/sessions/${sessionId}/skill_invoke`, { method: 'POST', body: JSON.stringify({ pane_id: paneId, skill_name: skillName, user_message: userMessage, }), }), }, agents: { list: (projectId: string) => request(`/api/projects/${projectId}/agents`), }, skills: { list: () => request<{ skills: Skill[] }>('/api/skills'), }, // v1.13.10: per-tool cost rolling-window stats (last 100 calls per tool, // equal-split attribution across multi-tool turns). Read endpoint backed by // the tool_cost_stats view. AgentPicker consumes this for per-agent cost // hints. tools: { costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'), }, settings: { get: () => request>('/api/settings'), patch: (body: Record) => request>('/api/settings', { method: 'PATCH', body: JSON.stringify(body), }), }, sidebar: { get: () => request('/api/sidebar'), }, // v1.10 booterm: REST control plane for terminal panes. WebSocket attach // lives at /ws/term/sessions/:sid/panes/:pid (handled directly by // TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a // `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is // gone, eliminating the race between WS attach and PTY-map registration. terminals: { // cols/rows are optional. When passed, booterm sizes the per-pane tmux // session at creation time so the inner bash (and any TUI it spawns) is // born with the correct PTY dimensions instead of tmux's 80x24 default. start: (sessionId: string, paneId: string, cols?: number, rows?: number) => request<{ tmux_session: string }>( `/api/term/sessions/${sessionId}/panes/${paneId}/start`, { method: 'POST', body: cols !== undefined && rows !== undefined ? JSON.stringify({ cols, rows }) : undefined, }, ), kill: (sessionId: string, paneId: string) => request<{ ok: true }>( `/api/term/sessions/${sessionId}/panes/${paneId}/kill`, { method: 'POST' }, ), }, };