import type { Task, TaskDetail, TaskAttachment, TaskComment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, BatchStatusResult, BatchStatusResponse, ActivityLogEntry, ActivityEventType, WorkflowStep, WorkflowStepInput, WorkflowStepResult, PluginInstallation, PluginUiSlotDefinition, TaskDocument, TaskDocumentRevision, TaskDocumentWithTask, Message, MessageMetadata, MessageType, ParticipantType, NodeConfig, NodeStatus, NodeMeshState, SystemMetrics, DiscoveryConfig, MissionEvent, MissionHealth, MissionEventType, AgentRating, AgentRatingSummary, AgentRatingInput, ChatSession, ChatMessage, EnrichedChatSession, Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff, Insight, InsightCategory, InsightStatus, InsightRun, InsightRunTrigger, TaskPriority, TaskSourceIssue, } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard"; import type { MilestoneValidationTelemetry } from "../components/mission-types"; import { appendTokenQuery, getAuthToken, withTokenHeader } from "../auth"; // Re-export skills types for use by hooks and components export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry }; export class ApiRequestError extends Error { readonly status: number; readonly details?: Record; constructor(message: string, status: number, details?: Record) { super(message); this.name = "ApiRequestError"; this.status = status; this.details = details; } } export interface DeleteTaskOptions { removeDependencyReferences?: boolean; } function looksLikeHtml(body: string): boolean { const trimmed = body.trim(); return trimmed.startsWith("(path: string, opts: RequestInit = {}): Promise { const url = buildApiUrl(path); const token = getAuthToken(); const headers = (() => { if (token) { const authenticatedHeaders = new Headers(opts.headers ?? {}); if (!authenticatedHeaders.has("Content-Type")) { authenticatedHeaders.set("Content-Type", "application/json"); } return withTokenHeader(authenticatedHeaders); } if (!opts.headers) { return { "Content-Type": "application/json" }; } const defaultHeaders = new Headers(opts.headers); if (!defaultHeaders.has("Content-Type")) { defaultHeaders.set("Content-Type", "application/json"); } return Object.fromEntries(defaultHeaders.entries()); })(); const res = await fetch(url, { ...opts, headers, }); // Handle successful 204 No Content responses (e.g., DELETE, reorder) // These return no body and no JSON content-type — return undefined for void endpoints if (res.status === 204) { if (!res.ok) { // 204 is always ok by definition, but guard anyway throw new Error(`Request failed for ${url}: ${res.status} ${res.statusText}`); } return undefined as T; } const contentType = res.headers.get("content-type") ?? ""; const bodyText = await res.text(); const isJson = contentType.includes("application/json"); const isHtml = contentType.includes("text/html") || looksLikeHtml(bodyText); if (isHtml) { throw new Error( `API returned HTML instead of JSON for ${url}. ` + `The endpoint may not be properly configured. (${res.status} ${res.statusText})` ); } if (!isJson) { const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText; throw new Error( `API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` + `(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}` ); } let data: unknown; try { data = bodyText ? JSON.parse(bodyText) : null; } catch { throw new Error( `API returned invalid JSON for ${url}. (${res.status} ${res.statusText})` ); } if (!res.ok) { const payload = data as { error?: string; details?: Record } | null; throw new ApiRequestError( payload?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`, res.status, payload?.details, ); } return data as T; } export interface DashboardHealthResponse { status: string; version: string; uptime: number; } export function fetchDashboardHealth(): Promise { return api("/health"); } export function fetchTasks( limit?: number, offset?: number, projectId?: string, q?: string, includeArchived?: boolean, ): Promise { const search = new URLSearchParams(); if (limit !== undefined) search.set("limit", String(limit)); if (offset !== undefined) search.set("offset", String(offset)); if (projectId) search.set("projectId", projectId); if (q) search.set("q", q); if (includeArchived) search.set("includeArchived", "1"); const suffix = search.size > 0 ? `?${search.toString()}` : ""; return api(`/tasks${suffix}`); } export async function fetchTaskDetail(id: string, projectId?: string): Promise { const maxAttempts = 2; // 1 initial + 1 retry const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId)); for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch(url, { headers: withTokenHeader({ "Content-Type": "application/json" }), }); const data = await res.json(); if (res.ok) return data as TaskDetail; if (attempt === maxAttempts) { throw new Error((data as { error?: string }).error || "Request failed"); } } // unreachable throw new Error("Request failed"); } export function createTask(input: TaskCreateInput, projectId?: string): Promise { const { title, description, column, dependencies, breakIntoSubtasks, enabledWorkflowSteps, assignedAgentId, modelPresetId, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, summarize, reviewLevel, executionMode, priority, } = input; return api(withProjectId("/tasks", projectId), { method: "POST", body: JSON.stringify({ title, description, column, dependencies, breakIntoSubtasks, enabledWorkflowSteps, assignedAgentId, modelPresetId, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, summarize, reviewLevel, executionMode, priority, }), }); } export function updateTask( id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; reviewLevel?: number | null; executionMode?: "standard" | "fast" | null; priority?: TaskPriority | null; sourceIssue?: TaskSourceIssue | null; }, projectId?: string, ): Promise { return api(withProjectId(`/tasks/${id}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** * Batch update AI model configuration for multiple tasks. * @param taskIds - Array of task IDs to update * @param modelProvider - Executor model provider (optional, null to clear) * @param modelId - Executor model ID (optional, null to clear) * @param validatorModelProvider - Validator model provider (optional, null to clear) * @param validatorModelId - Validator model ID (optional, null to clear) * @returns Promise with updated tasks and count */ export function batchUpdateTaskModels( taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null, planningModelProvider?: string | null, planningModelId?: string | null, projectId?: string, ): Promise<{ updated: Task[]; count: number }> { return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), { method: "POST", body: JSON.stringify({ taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, }), }); } export function moveTask(id: string, column: Column, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/move`, projectId), { method: "POST", body: JSON.stringify({ column }), }); } export function deleteTask(id: string, projectId?: string, options?: DeleteTaskOptions): Promise { const search = new URLSearchParams(); if (options?.removeDependencyReferences) { search.set("removeDependencyReferences", "true"); } const suffix = search.size > 0 ? `?${search.toString()}` : ""; return api(withProjectId(`/tasks/${id}${suffix}`, projectId), { method: "DELETE" }); } export function mergeTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/merge`, projectId), { method: "POST" }); } export function retryTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" }); } export function duplicateTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/duplicate`, projectId), { method: "POST" }); } export function pauseTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/pause`, projectId), { method: "POST" }); } export function unpauseTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" }); } export function archiveTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/archive`, projectId), { method: "POST" }); } export function unarchiveTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/unarchive`, projectId), { method: "POST" }); } export function archiveAllDone(projectId?: string): Promise { return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then( (response) => response.archived ); } export function approvePlan(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/approve-plan`, projectId), { method: "POST" }); } export function rejectPlan(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/reject-plan`, projectId), { method: "POST" }); } export function fetchConfig(projectId?: string): Promise<{ maxConcurrent: number; rootDir: string }> { return api<{ maxConcurrent: number; rootDir: string }>(withProjectId("/config", projectId)); } export function fetchSettings(projectId?: string): Promise { return api(withProjectId("/settings", projectId)); } export function updateSettings(settings: Partial, projectId?: string): Promise { return api(withProjectId("/settings", projectId), { method: "PUT", body: JSON.stringify(settings), }); } export interface RemoteSettings { remoteActiveProvider: "tailscale" | "cloudflare" | null; remoteTailscaleEnabled: boolean; remoteTailscaleHostname: string; remoteTailscaleTargetPort: number; remoteTailscaleAcceptRoutes: boolean; remoteCloudflareEnabled: boolean; remoteCloudflareTunnelName: string; remoteCloudflareTunnelToken: string | null; remoteCloudflareIngressUrl: string; remotePersistentToken: string | null; remoteShortLivedEnabled: boolean; remoteShortLivedTtlMs: number; remoteShortLivedMaxTtlMs: number; remoteRememberLastRunning: boolean; remoteWasRunningOnShutdown: boolean; remoteLastStartedProvider: "tailscale" | "cloudflare" | null; } export interface RemoteStatus { provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "stopping" | "failed"; url: string | null; lastError: string | null; lastErrorCode?: string | null; restore?: { outcome: "applied" | "skipped" | "failed"; reason: string; at: string; provider: "tailscale" | "cloudflare" | null; message?: string; }; } export function fetchRemoteSettings(projectId?: string): Promise<{ settings: RemoteSettings }> { return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId)); } export function updateRemoteSettings( settings: Partial, projectId?: string, ): Promise<{ settings: RemoteSettings }> { return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId), { method: "PUT", body: JSON.stringify(settings), }); } export function fetchRemoteStatus(projectId?: string): Promise { return api(withProjectId("/remote/status", projectId)); } export function activateRemoteProvider(provider: "tailscale" | "cloudflare", projectId?: string): Promise<{ activeProvider: "tailscale" | "cloudflare" }> { return api<{ activeProvider: "tailscale" | "cloudflare" }>(withProjectId("/remote/provider/activate", projectId), { method: "POST", body: JSON.stringify({ provider }), }); } export function startRemoteTunnel(projectId?: string): Promise<{ state: "starting" | "running"; provider: string }> { return api<{ state: "starting" | "running"; provider: string }>(withProjectId("/remote/tunnel/start", projectId), { method: "POST", }); } export function stopRemoteTunnel(projectId?: string): Promise<{ state: "stopped"; provider: string | null }> { return api<{ state: "stopped"; provider: string | null }>(withProjectId("/remote/tunnel/stop", projectId), { method: "POST", }); } export function regenerateRemotePersistentToken(projectId?: string): Promise<{ token: string; maskedToken: string }> { return api<{ token: string; maskedToken: string }>(withProjectId("/remote/token/persistent/regenerate", projectId), { method: "POST", }); } export function generateShortLivedRemoteToken(ttlMs: number, projectId?: string): Promise<{ token: string; expiresAt: string; ttlMs: number }> { return api<{ token: string; expiresAt: string; ttlMs: number }>(withProjectId("/remote/token/short-lived/generate", projectId), { method: "POST", body: JSON.stringify({ ttlMs }), }); } type RemoteAuthTokenType = "persistent" | "short-lived"; type RemoteLinkRequestOptions = { projectId?: string; tokenType?: RemoteAuthTokenType; ttlMs?: number; }; function buildRemoteAuthQuery( format: "text" | "image/svg" | null, tokenType: RemoteAuthTokenType, ttlMs?: number, ): string { const params = new URLSearchParams(); if (format) { params.set("format", format); } params.set("tokenType", tokenType); if (tokenType === "short-lived" && typeof ttlMs === "number" && Number.isFinite(ttlMs)) { params.set("ttlMs", String(ttlMs)); } const query = params.toString(); return query ? `?${query}` : ""; } export function fetchRemoteUrl( options: RemoteLinkRequestOptions = {}, ): Promise<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null }> { const { projectId, tokenType = "persistent", ttlMs } = options; const query = buildRemoteAuthQuery(null, tokenType, ttlMs); return api<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null }>(withProjectId(`/remote/url${query}`, projectId)); } export function fetchRemoteQr( format: "text" | "image/svg" = "text", options: RemoteLinkRequestOptions = {}, ): Promise<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null; format: "text" | "image/svg"; data?: string }> { const { projectId, tokenType = "persistent", ttlMs } = options; const query = buildRemoteAuthQuery(format, tokenType, ttlMs); return api<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>(withProjectId(`/remote/qr${query}`, projectId)); } export function fetchMemory(projectId?: string): Promise<{ content: string }> { return api<{ content: string }>(withProjectId("/memory", projectId)); } export function saveMemory(content: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId("/memory", projectId), { method: "PUT", body: JSON.stringify({ content }), }); } export interface MemoryFileInfo { path: string; label: string; layer: "long-term" | "daily" | "dreams"; size: number; updatedAt: string; } export function fetchMemoryFiles(projectId?: string): Promise<{ files: MemoryFileInfo[] }> { return api<{ files: MemoryFileInfo[] }>(withProjectId("/memory/files", projectId)); } export function fetchMemoryFile(path: string, projectId?: string): Promise<{ path: string; content: string }> { const query = `path=${encodeURIComponent(path)}`; return api<{ path: string; content: string }>(withProjectId(`/memory/file?${query}`, projectId)); } export function saveMemoryFile(path: string, content: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId("/memory/file", projectId), { method: "PUT", body: JSON.stringify({ path, content }), }); } /** * Compact memory content using AI to distill it down to the most important insights. * Reads one memory file, compacts it via AI, and writes the result back. * * Backwards-compatible call patterns: * - compactMemory(projectId?) * - compactMemory(path, projectId?) * * @param pathOrProjectId - Memory file path or legacy projectId-only argument * @param projectId - Optional project ID for multi-project support * @returns Promise resolving to the compacted memory content */ export function compactMemory( pathOrProjectId?: string, projectId?: string, ): Promise<{ path?: string; content: string }> { let path: string | undefined; let effectiveProjectId = projectId; if (projectId !== undefined) { path = pathOrProjectId; } else if (typeof pathOrProjectId === "string" && pathOrProjectId.trim().length > 0) { const trimmed = pathOrProjectId.trim(); const looksLikeMemoryPath = trimmed.includes("/") || trimmed.endsWith(".md") || trimmed.startsWith("."); if (looksLikeMemoryPath) { path = trimmed; } else { effectiveProjectId = trimmed; } } return api<{ path?: string; content: string }>(withProjectId("/memory/compact", effectiveProjectId), { method: "POST", body: JSON.stringify(path ? { path } : {}), }); } /** Memory audit report type (mirrors @fusion/core MemoryAuditReport) */ export interface MemoryAuditReport { generatedAt: string; workingMemory: { exists: boolean; size: number; sectionCount: number; lastModified?: string; }; insightsMemory: { exists: boolean; size: number; insightCount: number; categories: Record; lastUpdated?: string; }; extraction: { runAt: string; success: boolean; insightCount: number; duplicateCount: number; skippedCount: number; summary: string; error?: string; }; pruning: { applied: boolean; reason: string; sizeDelta: number; originalSize: number; newSize: number; }; checks: Array<{ id: string; name: string; passed: boolean; details: string; }>; health: "healthy" | "warning" | "issues"; } /** * Fetch memory insights content. * Returns { content: string | null, exists: boolean }. * content is null when no insights file exists yet. */ export function fetchMemoryInsights(projectId?: string): Promise<{ content: string | null; exists: boolean }> { return api<{ content: string | null; exists: boolean }>(withProjectId("/memory/insights", projectId)); } /** * Save memory insights content. * The insights file stores parsed long-term memory grouped by category. */ export function saveMemoryInsights(content: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId("/memory/insights", projectId), { method: "PUT", body: JSON.stringify({ content }), }); } /** * Trigger AI-powered insight extraction from working memory. * Reads working memory, generates insights via AI, merges/prunes existing insights, * and generates an audit report. * * Returns: { success: boolean, summary: string, insightCount: number, pruned: boolean } */ export function triggerInsightExtraction(projectId?: string): Promise<{ success: boolean; summary: string; insightCount: number; pruned: boolean }> { return api<{ success: boolean; summary: string; insightCount: number; pruned: boolean }>(withProjectId("/memory/extract", projectId), { method: "POST", }); } /** * Fetch memory audit report. * The audit checks working memory and insights memory state, extraction history, * and generates health recommendations. */ export function fetchMemoryAudit(projectId?: string): Promise { return api(withProjectId("/memory/audit", projectId)); } /** * Fetch quick memory stats (lightweight, no AI). * Useful for dashboard displays showing memory size and insight counts. * * Returns: { workingMemorySize: number, insightsSize: number, insightsExists: boolean } */ export function fetchMemoryStats(projectId?: string): Promise<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean }> { return api<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean }>(withProjectId("/memory/stats", projectId)); } /** * Memory backend capabilities returned by the backend status API. */ export interface MemoryBackendCapabilities { readable: boolean; writable: boolean; supportsAtomicWrite: boolean; hasConflictResolution: boolean; persistent: boolean; } /** * Memory backend status response from GET /api/memory/backend */ export interface MemoryBackendStatus { /** The effective backend type after runtime resolution */ currentBackend: string; /** Capabilities of the effective backend */ capabilities: MemoryBackendCapabilities; /** List of registered backend types available */ availableBackends: string[]; /** Whether the qmd CLI is available on PATH */ qmdAvailable?: boolean; /** Suggested install command when qmd is unavailable */ qmdInstallCommand?: string; } export interface MemorySearchResult { path: string; lineStart: number; lineEnd: number; snippet: string; score: number; backend: string; } export interface MemoryRetrievalTestResult { query: string; qmdAvailable: boolean; usedFallback: boolean; qmdInstallCommand: string; results: MemorySearchResult[]; } export interface QmdInstallResult { success: boolean; qmdAvailable: boolean; qmdInstallCommand: string; } /** * Fetch the current memory backend status and capabilities. * Use this to determine which backend is active and what operations it supports. */ export function fetchMemoryBackendStatus(projectId?: string): Promise { return api(withProjectId("/memory/backend", projectId)); } export function installQmd(projectId?: string): Promise { return api(withProjectId("/memory/install-qmd", projectId), { method: "POST", }); } export function testMemoryRetrieval(query: string, projectId?: string): Promise { return api(withProjectId("/memory/test", projectId), { method: "POST", body: JSON.stringify({ query }), }); } /** Fetch global (user-level) settings from ~/.fusion/settings.json */ export function fetchGlobalSettings(): Promise { return api("/settings/global"); } /** Update global (user-level) settings. These persist across all fn projects. */ export function updateGlobalSettings(settings: Partial): Promise { return api("/settings/global", { method: "PUT", body: JSON.stringify(settings), }); } /** Fetch settings separated by scope: { global, project } */ export function fetchSettingsByScope(projectId?: string): Promise<{ global: GlobalSettings; project: Partial }> { return api<{ global: GlobalSettings; project: Partial }>(withProjectId("/settings/scopes", projectId)); } export interface PiExtensionEntry { id: string; name: string; path: string; source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package"; enabled: boolean; } export interface PiExtensionSettings { extensions: PiExtensionEntry[]; disabledIds: string[]; settingsPath: string; } export function fetchPiExtensions(projectId?: string): Promise { return api(withProjectId("/settings/pi-extensions", projectId)); } export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise { return api(withProjectId("/settings/pi-extensions", projectId), { method: "PUT", body: JSON.stringify({ disabledIds }), }); } export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), { method: "POST", body: config ? JSON.stringify(config) : undefined, }); } /** Pi extension settings from ~/.pi/agent/settings.json (global scope) */ export interface PiSettings { packages: Array; extensions: string[]; skills: string[]; prompts: string[]; themes: string[]; } /** Fetch pi extension settings (global scope from ~/.pi/agent/settings.json) */ export function fetchPiSettings(): Promise { return api("/pi-settings"); } /** Update pi extension settings (partial update, global scope) */ export async function updatePiSettings(settings: Partial): Promise<{ success: boolean }> { return api<{ success: boolean }>("/pi-settings", { method: "PUT", body: JSON.stringify(settings), }); } /** Install a new pi package source (adds to ~/.pi/agent/settings.json) */ export async function installPiPackage(source: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/pi-settings/packages", { method: "POST", body: JSON.stringify({ source }), }); } /** Reinstall Fusion's bundled pi package and ensure it remains in global Pi settings. */ export async function reinstallFusionPiPackage(projectId?: string): Promise<{ success: boolean; source: string }> { return api<{ success: boolean; source: string }>(withProjectId("/pi-settings/reinstall-fusion", projectId), { method: "POST", }); } export async function uploadAttachment(id: string, file: File, projectId?: string): Promise { const formData = new FormData(); formData.append("file", file); const res = await fetch(buildApiUrl(withProjectId(`/tasks/${id}/attachments`, projectId)), { method: "POST", headers: withTokenHeader(), body: formData, }); const data = await res.json(); if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed"); return data as TaskAttachment; } export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" }); } export function fetchAgentLogs( taskId: string, projectId?: string, options?: { limit?: number; offset?: number }, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) { params.set("limit", String(options.limit)); } if (options?.offset !== undefined) { params.set("offset", String(options.offset)); } const suffix = params.toString() ? `?${params.toString()}` : ""; return api(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId)); } /** * Fetch agent logs with pagination metadata. * Returns entries along with total count and hasMore flag from response headers. */ export async function fetchAgentLogsWithMeta( taskId: string, projectId?: string, options?: { limit?: number; offset?: number }, ): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> { const params = new URLSearchParams(); if (options?.limit !== undefined) { params.set("limit", String(options.limit)); } if (options?.offset !== undefined) { params.set("offset", String(options.offset)); } const suffix = params.toString() ? `?${params.toString()}` : ""; const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId); const response = await fetch(buildApiUrl(url), { headers: withTokenHeader(), }); if (!response.ok) { const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" })); throw new Error((data as { error?: string }).error || `HTTP ${response.status}`); } const entries = await response.json() as AgentLogEntry[]; // Read pagination headers const total = response.headers.has("X-Total-Count") ? parseInt(response.headers.get("X-Total-Count")!, 10) : entries.length; const hasMore = response.headers.has("X-Has-More") ? response.headers.get("X-Has-More") === "true" : false; return { entries, total, hasMore }; } export function fetchSessionFiles(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/session-files`, projectId)); } export function fetchTaskComments(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/comments`, projectId)); } export function addTaskComment(id: string, text: string, author?: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/comments`, projectId), { method: "POST", body: JSON.stringify({ text, author }), }); } export function updateTaskComment(id: string, commentId: string, text: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { method: "PATCH", body: JSON.stringify({ text }), }); } export function deleteTaskComment(id: string, commentId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), { method: "DELETE", }); } // ── Task Document API Functions ────────────────────────────────────────────── export function fetchTaskDocuments(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/documents`, projectId)); } export function fetchTaskDocument(taskId: string, key: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId)); } export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/documents/${key}/revisions`, projectId)); } export interface FetchAllDocumentsOptions { q?: string; limit?: number; offset?: number; } export interface MarkdownFileEntry { path: string; name: string; size: number; mtime: string; } export interface MarkdownFileListResponse { files: MarkdownFileEntry[]; } export async function fetchAllDocuments( options?: FetchAllDocumentsOptions, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.q) params.set("q", options.q); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); const queryString = params.toString(); const path = `/documents${queryString ? `?${queryString}` : ""}`; return api(withProjectId(path, projectId)); } export interface FetchProjectMarkdownFilesOptions { showHidden?: boolean; } export function fetchProjectMarkdownFiles( projectId?: string, options?: FetchProjectMarkdownFilesOptions, ): Promise { const params = new URLSearchParams(); if (options?.showHidden) { params.set("showHidden", "1"); } const query = params.toString(); const path = `/files/markdown-list${query ? `?${query}` : ""}`; return api(withProjectId(path, projectId)); } export function putTaskDocument( taskId: string, key: string, content: string, opts?: { author?: string; metadata?: Record }, projectId?: string, ): Promise { return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { method: "PUT", body: JSON.stringify({ content, author: opts?.author, metadata: opts?.metadata, }), }); } export function deleteTaskDocument(taskId: string, key: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), { method: "DELETE", }); } export function addSteeringComment(id: string, text: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/steer`, projectId), { method: "POST", body: JSON.stringify({ text }), }); } export function requestSpecRevision(id: string, feedback: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/spec/revise`, projectId), { method: "POST", body: JSON.stringify({ feedback }), }); } export function rebuildTaskSpec(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/spec/rebuild`, projectId), { method: "POST", }); } export function refineTask(id: string, feedback: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/refine`, projectId), { method: "POST", body: JSON.stringify({ feedback }), }); } // --- Models API --- /** Available AI model info returned by the models endpoint */ export interface ModelInfo { provider: string; id: string; name: string; reasoning: boolean; contextWindow: number; } /** Response from the models endpoint */ export interface ModelsResponse { models: ModelInfo[]; favoriteProviders: string[]; favoriteModels: string[]; } /** Fetch available AI models from the model registry along with favoriteProviders */ export function fetchModels(): Promise { return api("/models"); } // --- Usage API --- /** Pace information for weekly usage windows */ export interface UsagePace { status: "ahead" | "on-track" | "behind"; percentElapsed: number; // 0-100, how much of the window time has passed message: string; // e.g., "Using 15% over your limit pace" } /** Usage window for a provider (e.g., "Session (5h)", "Weekly") */ export interface UsageWindow { label: string; percentUsed: number; // 0-100 percentLeft: number; // 0-100 resetText: string | null; // e.g., "resets in 2h" resetMs?: number; // ms until reset resetAt?: string; // ISO 8601 timestamp of when the window resets (machine-readable) windowDurationMs?: number; // total window length pace?: UsagePace; // pace indicator for weekly windows } /** Provider usage data */ export interface ProviderUsage { name: string; icon: string; // emoji status: "ok" | "error" | "no-auth"; error?: string; plan?: string | null; email?: string | null; windows: UsageWindow[]; } /** Fetch usage data from all configured AI providers */ export function fetchUsageData(): Promise<{ providers: ProviderUsage[] }> { return api<{ providers: ProviderUsage[] }>("/usage"); } // --- Auth API --- /** OAuth provider with current authentication status */ export interface AuthProvider { id: string; name: string; authenticated: boolean; /** * How this provider authenticates / is activated. * - "oauth": OAuth flow (user clicks Login → redirect) * - "api_key": API key stored locally * - "cli": a locally-installed CLI binary is the backing transport * (e.g. the synthetic `claude-cli` provider). Cards should render a * one-click Enable/Disable + Test button rather than login/key inputs. */ type?: "oauth" | "api_key" | "cli"; /** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */ keyHint?: string; } /** * Snapshot of the Claude-CLI-via-pi health state. Powers the * "Anthropic — via Claude CLI" provider card. */ export interface ClaudeCliStatus { binary: { available: boolean; version?: string; binaryPath?: string; reason?: string; probeDurationMs: number; }; enabled: boolean; extension: { status: "ok" | "not-installed" | "missing-entry" | "error"; path?: string; packageVersion?: string; reason?: string; } | null; ready: boolean; } /** Probe the local Claude CLI binary + setting + extension state. */ export function fetchClaudeCliStatus(): Promise { return api("/providers/claude-cli/status"); } /** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */ export function setClaudeCliEnabled( enabled: boolean, ): Promise<{ enabled: boolean; restartRequired: boolean }> { return api<{ enabled: boolean; restartRequired: boolean }>("/auth/claude-cli", { method: "POST", body: JSON.stringify({ enabled }), }); } /** Fetch authentication status for all OAuth providers */ export function fetchAuthStatus(): Promise<{ providers: AuthProvider[]; ghCli?: { available: boolean; authenticated: boolean }; }> { return api<{ providers: AuthProvider[]; ghCli?: { available: boolean; authenticated: boolean }; }>("/auth/status"); } /** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */ export function loginProvider(provider: string): Promise<{ url: string; instructions?: string }> { return api<{ url: string; instructions?: string }>("/auth/login", { method: "POST", body: JSON.stringify({ provider }), }); } /** Logout from a provider, removing stored credentials. */ export function logoutProvider(provider: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/auth/logout", { method: "POST", body: JSON.stringify({ provider }), }); } /** Save an API key for an API-key-backed provider. */ export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/auth/api-key", { method: "POST", body: JSON.stringify({ provider, apiKey }), }); } /** Remove an API key for an API-key-backed provider. */ export function clearApiKey(provider: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/auth/api-key", { method: "DELETE", body: JSON.stringify({ provider }), }); } // --- GitHub Import API --- /** GitHub issue returned by the fetch endpoint */ export interface GitHubIssue { number: number; title: string; body: string | null; html_url: string; labels: Array<{ name: string }>; } /** Fetch open GitHub issues from a repository */ export function apiFetchGitHubIssues( owner: string, repo: string, limit?: number, labels?: string[] ): Promise { return api("/github/issues/fetch", { method: "POST", body: JSON.stringify({ owner, repo, limit, labels }), }); } /** Import a specific GitHub issue as a fn task */ export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number, projectId?: string): Promise { return api(withProjectId("/github/issues/import", projectId), { method: "POST", body: JSON.stringify({ owner, repo, issueNumber }), }); } /** Result of a batch import operation for a single issue */ export interface BatchImportResult { issueNumber: number; success: boolean; taskId?: string; error?: string; skipped?: boolean; retryAfter?: number; } /** Batch import multiple GitHub issues as fn tasks with throttling */ export function apiBatchImportGitHubIssues( owner: string, repo: string, issueNumbers: number[], delayMs?: number, projectId?: string ): Promise<{ results: BatchImportResult[] }> { return api<{ results: BatchImportResult[] }>(withProjectId("/github/issues/batch-import", projectId), { method: "POST", body: JSON.stringify({ owner, repo, issueNumbers, delayMs }), }); } // --- GitHub Pull Request Import API --- /** GitHub pull request returned by the fetch endpoint */ export interface GitHubPull { number: number; title: string; body: string | null; html_url: string; headBranch: string; baseBranch: string; } /** Fetch open GitHub pull requests from a repository */ export function apiFetchGitHubPulls( owner: string, repo: string, limit?: number ): Promise { return api("/github/pulls/fetch", { method: "POST", body: JSON.stringify({ owner, repo, limit }), }); } /** Import a specific GitHub pull request as a fn review task */ export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise { return api(withProjectId("/github/pulls/import", projectId), { method: "POST", body: JSON.stringify({ owner, repo, prNumber }), }); } // --- Git Remote Detection API --- /** Git remote info returned by the remotes endpoint */ export interface GitRemote { name: string; owner: string; repo: string; url: string; } /** Fetch GitHub remotes from the current git repository */ export function fetchGitRemotes(projectId?: string): Promise { return api(withProjectId("/git/remotes", projectId)); } /** Detailed git remote info with fetch and push URLs */ export interface GitRemoteDetailed { name: string; fetchUrl: string; pushUrl: string; } /** Fetch all git remotes with their fetch and push URLs */ export function fetchGitRemotesDetailed(projectId?: string): Promise { return api(withProjectId("/git/remotes/detailed", projectId)); } /** Add a new git remote */ export function addGitRemote(name: string, url: string, projectId?: string): Promise { return api(withProjectId("/git/remotes", projectId), { method: "POST", body: JSON.stringify({ name, url }), }); } /** Remove a git remote */ export function removeGitRemote(name: string, projectId?: string): Promise { return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), { method: "DELETE", }); } /** Rename a git remote */ export function renameGitRemote(name: string, newName: string, projectId?: string): Promise { return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), { method: "PATCH", body: JSON.stringify({ newName }), }); } /** Update the URL for a git remote */ export function updateGitRemoteUrl(name: string, url: string, projectId?: string): Promise { return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), { method: "PUT", body: JSON.stringify({ url }), }); } // --- PR Management API --- /** PR info returned by PR endpoints */ export interface PrInfo { url: string; number: number; status: "open" | "closed" | "merged"; title: string; headBranch: string; baseBranch: string; commentCount: number; lastCommentAt?: string; lastCheckedAt?: string; } export interface PrCheckStatus { name: string; required: boolean; state: string; } export interface PrStatusResponse { prInfo: PrInfo; stale: boolean; automationStatus?: string | null; } export interface PrRefreshResponse { prInfo: PrInfo; mergeReady: boolean; blockingReasons: string[]; reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; checks: PrCheckStatus[]; automationStatus?: string | null; } /** Create a GitHub PR for a task */ export function createPr( id: string, params: { title: string; body?: string; base?: string }, projectId?: string, ): Promise { return api(withProjectId(`/tasks/${id}/pr/create`, projectId), { method: "POST", body: JSON.stringify(params), }); } /** Fetch cached PR status for a task */ export function fetchPrStatus(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/pr/status`, projectId)); } /** Force refresh PR status from GitHub */ export function refreshPrStatus(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/pr/refresh`, projectId), { method: "POST", }); } // --- Issue Management API --- /** Re-export GitHub badge-related types for convenience */ export type { IssueInfo, BatchStatusResult, BatchStatusEntry } from "@fusion/core"; /** Fetch cached issue status for a task */ export function fetchIssueStatus(id: string, projectId?: string): Promise<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }> { return api<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }>(withProjectId(`/tasks/${id}/issue/status`, projectId)); } /** Force refresh issue status from GitHub */ export function refreshIssueStatus(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/issue/refresh`, projectId), { method: "POST", }); } /** Batch-refresh cached GitHub badge status for multiple tasks. */ export async function fetchBatchStatus(taskIds: string[], projectId?: string): Promise { const response = await api(withProjectId("/github/batch/status", projectId), { method: "POST", body: JSON.stringify({ taskIds }), }); return response.results; } // --- Terminal API --- /** Terminal exec response - returns sessionId for streaming output via SSE */ export interface TerminalExecResponse { sessionId: string; } /** Terminal session status and output */ export interface TerminalSession { id: string; command: string; running: boolean; exitCode: number | null; output: string; startTime: string; } /** Terminal SSE event types */ export interface TerminalOutputEvent { type: "stdout" | "stderr"; data: string; } /** Terminal exit event from SSE */ export interface TerminalExitEvent { type: "exit"; exitCode: number; } /** Execute a shell command and get a session ID for streaming output */ export function execTerminalCommand(command: string, projectId?: string): Promise { return api(withProjectId("/terminal/exec", projectId), { method: "POST", body: JSON.stringify({ command }), }); } /** Get terminal session status and accumulated output */ export function getTerminalSession(sessionId: string): Promise { return api(`/terminal/sessions/${encodeURIComponent(sessionId)}`); } /** Kill a running terminal session */ export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIGKILL" | "SIGINT"): Promise<{ killed: boolean; sessionId: string }> { return api<{ killed: boolean; sessionId: string }>(`/terminal/sessions/${encodeURIComponent(sessionId)}/kill`, { method: "POST", body: JSON.stringify({ signal: signal ?? "SIGTERM" }), }); } /** Get the SSE stream URL for a terminal session */ export function getTerminalStreamUrl(sessionId: string): string { return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`; } // --- PTY Terminal API (WebSocket-based) --- /** PTY Terminal session response */ export interface PtyTerminalSession { sessionId: string; shell: string; cwd: string; } /** PTY Terminal session info for listing */ export interface PtyTerminalSessionInfo { id: string; cwd: string; shell: string; createdAt: string; } /** Create a new PTY terminal session */ export function createTerminalSession( cwd?: string, cols?: number, rows?: number, projectId?: string ): Promise { return api(withProjectId("/terminal/sessions", projectId), { method: "POST", body: JSON.stringify({ cwd, cols, rows }), }); } /** Kill a PTY terminal session */ export function killPtyTerminalSession(sessionId: string, projectId?: string): Promise<{ killed: boolean }> { return api<{ killed: boolean }>(withProjectId(`/terminal/sessions/${encodeURIComponent(sessionId)}`, projectId), { method: "DELETE", }); } /** List active PTY terminal sessions */ export function listTerminalSessions(projectId?: string): Promise { return api(withProjectId("/terminal/sessions", projectId)); } // --- Git Management API --- /** Current git status */ export interface GitStatus { branch: string; commit: string; isDirty: boolean; ahead: number; behind: number; } /** Git commit info */ export interface GitCommit { hash: string; shortHash: string; message: string; author: string; date: string; parents: string[]; } /** Git branch info */ export interface GitBranch { name: string; isCurrent: boolean; remote?: string; lastCommitDate?: string; } /** Git worktree info */ export interface GitWorktree { path: string; branch?: string; isMain: boolean; isBare: boolean; taskId?: string; } /** Result of a fetch operation */ export interface GitFetchResult { fetched: boolean; message: string; } /** Result of a pull operation */ export interface GitPullResult { success: boolean; message: string; conflict?: boolean; } /** Result of a push operation */ export interface GitPushResult { success: boolean; message: string; } /** Fetch current git status */ export function fetchGitStatus(projectId?: string): Promise { return api(withProjectId("/git/status", projectId)); } /** Fetch recent commits */ export function fetchGitCommits(limit?: number, projectId?: string): Promise { const query = limit ? `?limit=${limit}` : ""; return api(withProjectId(`/git/commits${query}`, projectId)); } /** Fetch diff for a specific commit */ export function fetchCommitDiff(hash: string, projectId?: string): Promise<{ stat: string; patch: string }> { return api<{ stat: string; patch: string }>(withProjectId(`/git/commits/${hash}/diff`, projectId)); } /** Fetch local commits ahead of the upstream tracking branch (commits to push) */ export function fetchAheadCommits(projectId?: string): Promise { return api(withProjectId("/git/commits/ahead", projectId)); } /** Fetch recent commits for a specific remote */ export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string): Promise { const params = new URLSearchParams(); if (ref) params.set("ref", ref); if (limit) params.set("limit", String(limit)); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId)); } /** Fetch all local branches */ export function fetchGitBranches(projectId?: string): Promise { return api(withProjectId("/git/branches", projectId)); } /** Fetch recent commits for a specific branch */ export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise { const query = limit ? `?limit=${limit}` : ""; return api(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId)); } /** Fetch all worktrees */ export function fetchGitWorktrees(projectId?: string): Promise { return api(withProjectId("/git/worktrees", projectId)); } /** Create a new branch */ export function createBranch(name: string, base?: string, projectId?: string): Promise { return api(withProjectId("/git/branches", projectId), { method: "POST", body: JSON.stringify({ name, base }), }); } /** Checkout an existing branch */ export function checkoutBranch(name: string, projectId?: string): Promise { return api(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), { method: "POST", }); } /** Delete a branch */ export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise { const query = force ? "?force=true" : ""; return api(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), { method: "DELETE", }); } /** Fetch from remote */ export function fetchRemote(remote?: string, projectId?: string): Promise { return api(withProjectId("/git/fetch", projectId), { method: "POST", body: JSON.stringify({ remote }), }); } /** Pull current branch */ export function pullBranch(projectId?: string): Promise { return api(withProjectId("/git/pull", projectId), { method: "POST", }); } /** Push current branch */ export function pushBranch(projectId?: string): Promise { return api(withProjectId("/git/push", projectId), { method: "POST", }); } /** Git stash entry */ export interface GitStash { index: number; message: string; date: string; branch: string; } /** Individual file change with staging status */ export interface GitFileChange { file: string; status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked"; staged: boolean; oldFile?: string; } /** Fetch stash list */ export function fetchGitStashList(projectId?: string): Promise { return api(withProjectId("/git/stashes", projectId)); } /** Create a new stash */ export function createStash(message?: string, projectId?: string): Promise<{ message: string }> { return api<{ message: string }>(withProjectId("/git/stashes", projectId), { method: "POST", body: JSON.stringify({ message }), }); } /** Apply a stash entry */ export function applyStash(index: number, drop?: boolean, projectId?: string): Promise<{ message: string }> { return api<{ message: string }>(withProjectId(`/git/stashes/${index}/apply`, projectId), { method: "POST", body: JSON.stringify({ drop }), }); } /** Drop a stash entry */ export function dropStash(index: number, projectId?: string): Promise<{ message: string }> { return api<{ message: string }>(withProjectId(`/git/stashes/${index}`, projectId), { method: "DELETE", }); } /** Fetch unstaged diff (working directory changes) */ export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> { return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId)); } /** Fetch diff for a specific file in staged or unstaged mode */ export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> { const params = new URLSearchParams(); params.set("path", path); params.set("staged", String(staged)); return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId)); } /** Fetch file changes (staged and unstaged) */ export function fetchFileChanges(projectId?: string): Promise { return api(withProjectId("/git/changes", projectId)); } /** Stage specific files */ export function stageFiles(files: string[], projectId?: string): Promise<{ staged: string[] }> { return api<{ staged: string[] }>(withProjectId("/git/stage", projectId), { method: "POST", body: JSON.stringify({ files }), }); } /** Unstage specific files */ export function unstageFiles(files: string[], projectId?: string): Promise<{ unstaged: string[] }> { return api<{ unstaged: string[] }>(withProjectId("/git/unstage", projectId), { method: "POST", body: JSON.stringify({ files }), }); } /** Create a commit */ export function createCommit(message: string, projectId?: string): Promise<{ hash: string; message: string }> { return api<{ hash: string; message: string }>(withProjectId("/git/commit", projectId), { method: "POST", body: JSON.stringify({ message }), }); } /** Discard changes in working directory for specific files */ export function discardChanges(files: string[], projectId?: string): Promise<{ discarded: string[] }> { return api<{ discarded: string[] }>(withProjectId("/git/discard", projectId), { method: "POST", body: JSON.stringify({ files }), }); } // --- File Browser API --- /** File node in directory listing */ export interface FileNode { name: string; type: "file" | "directory"; size?: number; mtime?: string; } /** File listing response */ export interface FileListResponse { path: string; entries: FileNode[]; } /** File content response */ export interface FileContentResponse { content: string; mtime: string; size: number; } /** Save file response */ export interface SaveFileResponse { success: true; mtime: string; size: number; } /** List files in task directory */ export function fetchFileList(taskId: string, path?: string, projectId?: string): Promise { const query = path ? `?path=${encodeURIComponent(path)}` : ""; return api(withProjectId(`/tasks/${taskId}/files${query}`, projectId)); } /** Fetch file content */ export function fetchFileContent(taskId: string, filePath: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, projectId)); } /** Save file content */ export function saveFileContent(taskId: string, filePath: string, content: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, projectId), { method: "POST", body: JSON.stringify({ content }), }); } // --- Workspace File Browser API --- export interface WorkspaceTaskInfo { id: string; title?: string; worktree: string; } export interface WorkspaceListResponse { project: string; tasks: WorkspaceTaskInfo[]; } /** Fetch available file browser workspaces. */ export function fetchWorkspaces(projectId?: string): Promise { return api(withProjectId("/workspaces", projectId)); } /** List files in a workspace (project root or task worktree). */ export function fetchWorkspaceFileList(workspace: string, path?: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (path) { query.set("path", path); } if (projectId) { query.set("projectId", projectId); } return api(`/files?${query.toString()}`); } /** Fetch file content from a workspace. */ export function fetchWorkspaceFileContent(workspace: string, filePath: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}?${query.toString()}`); } /** Save file content to a workspace. */ export function saveWorkspaceFileContent(workspace: string, filePath: string, content: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, { method: "POST", body: JSON.stringify({ content }), }); } /** File search result. */ export interface FileSearchResult { files: Array<{ path: string; name: string }>; } /** Search for files matching a query in a workspace. */ export function searchFiles(query: string, workspace?: string, projectId?: string): Promise { const params = new URLSearchParams({ q: query }); if (workspace) { params.set("workspace", workspace); } if (projectId) { params.set("projectId", projectId); } return api(`/files/search?${params.toString()}`); } // --- Workspace File Operations API (Copy, Move, Delete, Rename, Download) --- /** File operation response for copy/move/delete/rename operations */ export interface FileOperationResponse { success: true; message?: string; } /** Copy a file or directory to a new location within a workspace. */ export function copyFile(workspace: string, filePath: string, destination: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}/copy?${query.toString()}`, { method: "POST", body: JSON.stringify({ destination }), }); } /** Move a file or directory to a new location within a workspace. */ export function moveFile(workspace: string, filePath: string, destination: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}/move?${query.toString()}`, { method: "POST", body: JSON.stringify({ destination }), }); } /** Delete a file or directory within a workspace. */ export function deleteFile(workspace: string, filePath: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}/delete?${query.toString()}`, { method: "POST", }); } /** Rename a file or directory within a workspace. */ export function renameFile(workspace: string, filePath: string, newName: string, projectId?: string): Promise { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return api(`/files/${encodeURIComponent(filePath)}/rename?${query.toString()}`, { method: "POST", body: JSON.stringify({ newName }), }); } /** Get the download URL for a single file in a workspace. */ export function downloadFileUrl(workspace: string, filePath: string, projectId?: string): string { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`; } /** Get the download URL for a folder as ZIP in a workspace. */ export function downloadZipUrl(workspace: string, filePath: string, projectId?: string): string { const query = new URLSearchParams({ workspace }); if (projectId) { query.set("projectId", projectId); } return `/api/files/${encodeURIComponent(filePath)}/download-zip?${query.toString()}`; } // --- Planning Mode API --- /** Planning session state returned from API */ export interface PlanningSession { sessionId: string; currentQuestion: PlanningQuestion | null; summary: PlanningSummary | null; } export interface SubtaskItem { id: string; title: string; description: string; suggestedSize: "S" | "M" | "L"; dependsOn: string[]; } /** SSE event types for planning session streaming */ export type PlanningStreamEvent = | { type: "thinking"; data: string } | { type: "question"; data: PlanningQuestion } | { type: "summary"; data: PlanningSummary } | { type: "error"; data: string } | { type: "complete"; data: Record }; /** Start a new planning session with an initial plan */ export function startPlanning(initialPlan: string, projectId?: string): Promise { return api(withProjectId("/planning/start", projectId), { method: "POST", body: JSON.stringify({ initialPlan }), }); } /** Start a new planning session with AI streaming support */ export function startPlanningStreaming( initialPlan: string, projectId?: string, modelOverride?: { planningModelProvider?: string; planningModelId?: string } ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), { method: "POST", body: JSON.stringify({ initialPlan, planningModelProvider: modelOverride?.planningModelProvider, planningModelId: modelOverride?.planningModelId, }), }); } /** Submit a response to the current planning question */ export function respondToPlanning( sessionId: string, responses: Record, projectId?: string, tabId?: string, ): Promise { return api(withProjectId("/planning/respond", projectId), { method: "POST", body: JSON.stringify({ sessionId, responses, tabId }), }); } /** Retry a failed planning session turn */ export function retryPlanningSession( sessionId: string, projectId?: string, tabId?: string, ): Promise<{ success: boolean; sessionId: string }> { return api<{ success: boolean; sessionId: string }>( withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId), { method: "POST", ...(tabId ? { body: JSON.stringify({ tabId }) } : {}), }, ); } /** Cancel an active planning session */ export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise { return api(withProjectId("/planning/cancel", projectId), { method: "POST", body: JSON.stringify({ sessionId, tabId }), }); } /** Create a task from a completed planning session */ export function createTaskFromPlanning( sessionId: string, summary?: PlanningSummary, projectId?: string, ): Promise { return api(withProjectId("/planning/create-task", projectId), { method: "POST", body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }), }); } /** Start subtask breakdown from a completed planning session */ export function startPlanningBreakdown( sessionId: string, summary?: PlanningSummary, projectId?: string, ): Promise<{ sessionId: string; subtasks: SubtaskItem[] }> { return api<{ sessionId: string; subtasks: SubtaskItem[] }>( withProjectId("/planning/start-breakdown", projectId), { method: "POST", body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }), }, ); } /** Create multiple tasks from a completed planning session */ export function createTasksFromPlanning( planningSessionId: string, subtasks: Array<{ id: string; title: string; description: string; suggestedSize: "S" | "M" | "L"; dependsOn: string[]; }>, projectId?: string, ): Promise<{ tasks: Task[] }> { return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), { method: "POST", body: JSON.stringify({ planningSessionId, subtasks }), }); } type StreamConnectionState = "connected" | "reconnecting"; // Track every live createResilientEventSource instance so we can close their // underlying EventSource sockets on page unload. Without this, Chrome holds // the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts // its 6-per-origin limit after ~3 refreshes, and every new fetch stalls — // leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own // handler; this one covers the parallel EventSource path in api.ts. const activeResilientEventSources = new Set<{ close: () => void }>(); if (typeof window !== "undefined") { const closeAll = () => { for (const handle of Array.from(activeResilientEventSources)) { try { handle.close(); } catch { /* best effort */ } } }; window.addEventListener("pagehide", closeAll); window.addEventListener("beforeunload", closeAll); } interface ResilientEventSourceOptions { maxReconnectAttempts?: number; onConnectionStateChange?: (state: StreamConnectionState) => void; onFatalError?: (message: string) => void; } interface ResilientEventHandlers { onOpen?: () => void; onMessage?: (event: MessageEvent) => void; events?: Record void>; } function appendLastEventId(url: string, lastEventId: number | null): string { if (lastEventId === null || lastEventId <= 0) { return url; } const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}lastEventId=${encodeURIComponent(String(lastEventId))}`; } function createResilientEventSource( url: string, handlers: ResilientEventHandlers, options: ResilientEventSourceOptions = {}, ): { close: () => void; isConnected: () => boolean } { const maxReconnectAttempts = options.maxReconnectAttempts ?? 10; let eventSource: EventSource | null = null; let closedByUser = false; let reconnectAttempts = 0; let reconnectTimer: ReturnType | null = null; let lastSeenEventId: number | null = null; let reconnectingNotified = false; const shouldDispatch = (event: MessageEvent): boolean => { const rawId = event.lastEventId; if (!rawId) { return true; } const parsedId = Number.parseInt(rawId, 10); if (!Number.isFinite(parsedId)) { return true; } if (lastSeenEventId !== null && parsedId <= lastSeenEventId) { return false; } lastSeenEventId = parsedId; return true; }; const connect = (): void => { if (closedByUser) return; const nextUrl = appendLastEventId(url, lastSeenEventId); // EventSource can't set headers — carry the bearer token via `fn_token=`. const source = new EventSource(appendTokenQuery(nextUrl)); eventSource = source; source.onopen = () => { reconnectAttempts = 0; reconnectingNotified = false; options.onConnectionStateChange?.("connected"); handlers.onOpen?.(); }; source.onmessage = (event) => { const messageEvent = event as MessageEvent; if (!shouldDispatch(messageEvent)) return; handlers.onMessage?.(messageEvent); }; for (const [eventName, handler] of Object.entries(handlers.events ?? {})) { source.addEventListener(eventName, (event: Event) => { const messageEvent = event as MessageEvent; if (!shouldDispatch(messageEvent)) return; handler(messageEvent); }); } source.onerror = () => { if (closedByUser || eventSource !== source) return; const readyState = source.readyState; if (readyState === EventSource.CONNECTING) { if (!reconnectingNotified) { reconnectingNotified = true; options.onConnectionStateChange?.("reconnecting"); } return; } source.close(); if (reconnectAttempts >= maxReconnectAttempts) { options.onFatalError?.("Connection lost"); return; } reconnectingNotified = true; options.onConnectionStateChange?.("reconnecting"); reconnectAttempts += 1; const delayMs = Math.min(1000 * 2 ** (reconnectAttempts - 1), 30000); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delayMs); }; }; connect(); const handle = { close: () => { closedByUser = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } eventSource?.close(); activeResilientEventSources.delete(handle); }, isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN, }; activeResilientEventSources.add(handle); return handle; } export interface DevServerCandidate { scriptName: string; command: string; packagePath: string; confidence: number; name: string; cwd: string; source: string; workspaceName?: string; label: string; } // Backward-compatible alias for backend naming in FN-2178 scope. export type DetectedCandidate = DevServerCandidate; export interface DevServerState { id: string; name: string; status: "stopped" | "starting" | "running" | "failed"; command: string; scriptName: string; cwd: string; pid?: number; startedAt?: string; previewUrl?: string; detectedUrl?: string; detectedPort?: number; manualPreviewUrl?: string; manualUrl?: string; logs: string[]; exitCode?: number | null; } export type DevServerStatus = DevServerState; export interface DevServerStartInput { command: string; scriptName?: string; cwd?: string; packagePath?: string; } export interface DevServerConfig { selectedScript: string | null; selectedSource: string | null; selectedCommand: string | null; previewUrlOverride: string | null; detectedPreviewUrl: string | null; selectedAt: string | null; } export interface DevServerLogHistoryEntry { id: number; text: string; stream: "stdout" | "stderr"; timestamp: string; } export interface DevServerLogHistoryResponse { lines: DevServerLogHistoryEntry[]; totalLines: number; } export interface FetchDevServerLogHistoryOptions { maxLines?: number; offset?: number; lastEventId?: number; } export interface DevServerConfig { selectedScript: string | null; selectedSource: string | null; selectedCommand: string | null; previewUrlOverride: string | null; detectedPreviewUrl: string | null; selectedAt: string | null; } interface BackendDevServerCandidate { name: string; command: string; source?: string; packageName?: string; packagePath?: string; confidence?: number; } interface BackendDevServerState { id?: string; name?: string; status?: "stopped" | "starting" | "running" | "failed"; command?: string; scriptId?: string; cwd?: string; pid?: number; startedAt?: string; previewUrl?: string; detectedUrl?: string; detectedPort?: number; manualPreviewUrl?: string; manualUrl?: string; logHistory?: string[]; exitCode?: number | null; } interface BackendDevServerLogHistoryLine { id?: number; text?: string; line?: string; stream?: "stdout" | "stderr"; timestamp?: string; } interface BackendDevServerLogHistoryResponse { lines?: BackendDevServerLogHistoryLine[]; totalLines?: number; } function mapBackendCandidateToFrontend(candidate: BackendDevServerCandidate): DevServerCandidate { const source = typeof candidate.source === "string" && candidate.source.trim().length > 0 ? candidate.source.trim() : "root"; const cwd = source === "root" ? "." : source; const scriptName = candidate.name; const packagePath = typeof candidate.packagePath === "string" && candidate.packagePath.trim().length > 0 ? candidate.packagePath.trim() : cwd; const confidence = typeof candidate.confidence === "number" ? candidate.confidence : 1; const locationLabel = source === "root" ? "root" : source; const packageLabel = typeof candidate.packageName === "string" && candidate.packageName.trim().length > 0 ? candidate.packageName.trim() : "project"; return { name: candidate.name, command: candidate.command, scriptName, packagePath, confidence, cwd, source, workspaceName: typeof candidate.packageName === "string" ? candidate.packageName : undefined, label: `${packageLabel} · ${scriptName} (${locationLabel})`, }; } function mapBackendStateToFrontend(state: BackendDevServerState): DevServerState { const status = state.status; const normalizedStatus = status === "starting" || status === "running" || status === "failed" || status === "stopped" ? status : "stopped"; const previewUrl = typeof state.previewUrl === "string" ? state.previewUrl : state.detectedUrl; const manualPreviewUrl = typeof state.manualPreviewUrl === "string" ? state.manualPreviewUrl : state.manualUrl; return { id: typeof state.id === "string" ? state.id : "", name: typeof state.name === "string" && state.name.length > 0 ? state.name : "default", status: normalizedStatus, command: typeof state.command === "string" ? state.command : "", scriptName: typeof state.scriptId === "string" ? state.scriptId : "", cwd: typeof state.cwd === "string" ? state.cwd : "", pid: state.pid, startedAt: state.startedAt, previewUrl, detectedUrl: typeof state.detectedUrl === "string" ? state.detectedUrl : previewUrl, detectedPort: state.detectedPort, manualPreviewUrl, manualUrl: typeof state.manualUrl === "string" ? state.manualUrl : manualPreviewUrl, logs: Array.isArray(state.logHistory) ? state.logHistory : [], exitCode: state.exitCode, }; } function normalizeDevServerLogLine(line: BackendDevServerLogHistoryLine, fallbackId: number): DevServerLogHistoryEntry { return { id: typeof line.id === "number" && Number.isFinite(line.id) ? line.id : fallbackId, text: typeof line.text === "string" ? line.text : (typeof line.line === "string" ? line.line : ""), stream: line.stream === "stderr" ? "stderr" : "stdout", timestamp: typeof line.timestamp === "string" ? line.timestamp : "", }; } function normalizeDevServerLogHistoryResponse(response: BackendDevServerLogHistoryResponse): DevServerLogHistoryResponse { const rawLines = Array.isArray(response.lines) ? response.lines : []; const lines = rawLines.map((line, index) => normalizeDevServerLogLine(line, index + 1)); return { lines, totalLines: typeof response.totalLines === "number" && Number.isFinite(response.totalLines) ? response.totalLines : lines.length, }; } function mapLegacyDevServerLogs(logs: string[], options: FetchDevServerLogHistoryOptions): DevServerLogHistoryResponse { const maxLines = typeof options.maxLines === "number" && Number.isFinite(options.maxLines) ? Math.max(1, Math.floor(options.maxLines)) : 100; const offset = typeof options.offset === "number" && Number.isFinite(options.offset) ? Math.max(0, Math.floor(options.offset)) : 0; const lastEventId = typeof options.lastEventId === "number" && Number.isFinite(options.lastEventId) ? Math.max(0, Math.floor(options.lastEventId)) : null; const totalLines = logs.length; const fullLines = logs.map((text, index) => ({ id: index + 1, text, stream: "stdout", timestamp: "", })); if (lastEventId !== null) { return { lines: fullLines.filter((line) => line.id > lastEventId).slice(0, maxLines), totalLines, }; } const endExclusive = Math.max(totalLines - offset, 0); const start = Math.max(endExclusive - maxLines, 0); return { lines: fullLines.slice(start, endExclusive), totalLines, }; } type DevServerCandidatesResponse = | { candidates?: BackendDevServerCandidate[] } | BackendDevServerCandidate[]; function mapCandidatesResponse(response: DevServerCandidatesResponse): DevServerCandidate[] { if (Array.isArray(response)) { return response.map(mapBackendCandidateToFrontend); } return (response.candidates ?? []).map(mapBackendCandidateToFrontend); } export async function fetchDevServerCandidates(projectId?: string): Promise { try { const response = await api(withProjectId("/dev-server/candidates", projectId)); return mapCandidatesResponse(response); } catch (error) { // Backward compatibility for workspaces that still expose /dev-server/detect. if (error instanceof Error && /\/dev-server\/candidates/.test(error.message)) { const fallback = await api(withProjectId("/dev-server/detect", projectId)); return mapCandidatesResponse(fallback); } throw error; } } export function detectDevServer(projectId?: string): Promise { return fetchDevServerCandidates(projectId); } export function fetchDevServerConfig(projectId?: string): Promise { return api(withProjectId("/dev-server/config", projectId)); } export function saveDevServerConfig(config: Partial, projectId?: string): Promise { return api(withProjectId("/dev-server/config", projectId), { method: "PUT", body: JSON.stringify(config), }); } export function fetchDevServerStatus(projectId?: string): Promise { return api(withProjectId("/dev-server/status", projectId)).then(mapBackendStateToFrontend); } export async function fetchDevServerLogHistory( options: FetchDevServerLogHistoryOptions = {}, projectId?: string, ): Promise { const query = new URLSearchParams(); if (typeof options.maxLines === "number" && Number.isFinite(options.maxLines)) { query.set("maxLines", String(Math.max(1, Math.floor(options.maxLines)))); } if (typeof options.offset === "number" && Number.isFinite(options.offset)) { query.set("offset", String(Math.max(0, Math.floor(options.offset)))); } if (typeof options.lastEventId === "number" && Number.isFinite(options.lastEventId)) { query.set("lastEventId", String(Math.max(0, Math.floor(options.lastEventId)))); } const suffix = query.size > 0 ? `?${query.toString()}` : ""; try { const response = await api( withProjectId(`/dev-server/logs/history${suffix}`, projectId), ); return normalizeDevServerLogHistoryResponse(response); } catch (error) { // Backward compatibility for workspaces without /dev-server/logs/history. if (error instanceof Error && /\/dev-server\/logs\/history/.test(error.message)) { const status = await fetchDevServerStatus(projectId); return mapLegacyDevServerLogs(status.logs, options); } throw error; } } export function startDevServer(body: DevServerStartInput, projectId?: string): Promise { const cwd = body.cwd ?? body.packagePath ?? "."; const scriptName = body.scriptName; return api(withProjectId("/dev-server/start", projectId), { method: "POST", body: JSON.stringify({ command: body.command, scriptName, scriptId: scriptName, cwd, packagePath: body.packagePath, }), }).then(mapBackendStateToFrontend); } export function stopDevServer(projectId?: string): Promise { return api(withProjectId("/dev-server/stop", projectId), { method: "POST", }).then(mapBackendStateToFrontend); } export function restartDevServer(projectId?: string): Promise { return api(withProjectId("/dev-server/restart", projectId), { method: "POST", }).then(mapBackendStateToFrontend); } export async function setDevServerPreviewUrl(urlOrBody: string | { url: string | null }, projectId?: string): Promise { const body = typeof urlOrBody === "string" ? { url: urlOrBody } : urlOrBody; try { const response = await api(withProjectId("/dev-server/preview-url", projectId), { method: "POST", body: JSON.stringify(body), }); return mapBackendStateToFrontend(response); } catch (error) { // Backward compatibility for workspaces that still use PUT. if (error instanceof Error && /\/dev-server\/preview-url/.test(error.message)) { const fallback = await api(withProjectId("/dev-server/preview-url", projectId), { method: "PUT", body: JSON.stringify(body), }); return mapBackendStateToFrontend(fallback); } throw error; } } export function getDevServerLogsStreamUrl(projectId?: string): string { return buildApiUrl(withProjectId("/dev-server/logs/stream", projectId)); } // ============================================================================= // Session-based DevServer API (FN-2184 / FN-2185) // Target /api/devserver/* with fallback to /api/dev-server/* for migration safety // ============================================================================= /** * Canonical session-based DevServer types. * These align with the new session model introduced in FN-2184. */ // Detected dev server command (result of detectDevServerCommands) export interface DetectedDevServerCommand { name: string; command: string; cwd: string; scriptName: string; packagePath: string; framework?: string; } // Dev server log entry format export interface DevServerLogEntry { timestamp: string; stream: "stdout" | "stderr"; text: string; } // Preview URL response from backend export interface DevServerPreviewResponse { url: string | null; source: "auto" | "manual" | null; } // Dev server runtime info (process details) export interface DevServerRuntime { pid: number; startedAt: string; exitCode?: number; previewUrl?: string; } // Dev server configuration (saved settings) export interface DevServerSessionConfig { id: string; name: string; command: string; cwd: string; env?: Record; autoStart?: boolean; } // Full DevServer session combining config, status, runtime, and logs export interface DevServerSession { config: DevServerSessionConfig; status: "stopped" | "starting" | "running" | "failed" | "stopping"; runtime?: DevServerRuntime; previewUrl?: string; logHistory: DevServerLogEntry[]; } // Options for fetching log history export interface FetchDevServerLogsOptions { maxLines?: number; offset?: number; lastEventId?: number; } // Backend response shape for log history interface BackendSessionLogResponse { lines?: DevServerLogEntry[]; totalLines?: number; } // Backend response for preview endpoint interface BackendPreviewResponse { url?: string | null; source?: string | null; } // Backend response for list sessions interface BackendSessionsListResponse { sessions?: DevServerSession[]; } // Backend response for detect commands interface BackendDetectCommandsResponse { candidates?: DetectedDevServerCommand[]; } /** * Fetch all dev server sessions. * Targets /api/devserver with fallback to /api/dev-server (legacy compatibility). */ export async function fetchDevServers(projectId?: string): Promise { try { const response = await api(withProjectId("/devserver", projectId)); return response.sessions ?? []; } catch { // Fallback: try to get the legacy single-server state and wrap it in session format try { const legacy = await fetchDevServerStatus(projectId); // Convert legacy state to session format const session: DevServerSession = { config: { id: legacy.id ?? "default", name: legacy.name ?? "Dev Server", command: legacy.command ?? "", cwd: legacy.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; return [session]; } catch { return []; } } } /** * Create a new dev server session. * Targets /api/devserver with fallback to /api/dev-server/start (legacy compatibility). */ export async function createDevServer( data: { command: string; cwd?: string; name?: string; env?: Record }, projectId?: string, ): Promise { const body = { command: data.command, cwd: data.cwd ?? ".", name: data.name, env: data.env, }; try { return await api(withProjectId("/devserver", projectId), { method: "POST", body: JSON.stringify(body), }); } catch { // Fallback: use legacy start endpoint const legacy = await startDevServer({ command: data.command, cwd: data.cwd }, projectId); return { config: { id: legacy.id ?? "default", name: legacy.name ?? data.name ?? "Dev Server", command: legacy.command, cwd: legacy.cwd ?? data.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; } } /** * Fetch a specific dev server session by ID. * Targets /api/devserver/:id with fallback to /api/dev-server/status (legacy compatibility). */ export async function fetchDevServer(id: string, projectId?: string): Promise { try { return await api(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId)); } catch { // Fallback: try legacy status endpoint (single-server model) try { const legacy = await fetchDevServerStatus(projectId); // If no ID or ID matches default, return legacy state as session if (!id || id === "default" || id === legacy.id) { return { config: { id: legacy.id ?? "default", name: legacy.name ?? "Dev Server", command: legacy.command ?? "", cwd: legacy.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; } return null; } catch { return null; } } } /** * Start a specific dev server by ID. * Targets /api/devserver/:id/start with fallback to /api/dev-server/start (legacy compatibility). */ export async function startDevServerById(id: string, projectId?: string): Promise { try { return await api(withProjectId(`/devserver/${encodeURIComponent(id)}/start`, projectId), { method: "POST", }); } catch { // Fallback: use legacy start endpoint (single-server model) const legacy = await startDevServer({ command: "" }, projectId); return { config: { id: legacy.id ?? id, name: legacy.name ?? "Dev Server", command: legacy.command ?? "", cwd: legacy.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; } } /** * Stop a specific dev server by ID. * Targets /api/devserver/:id/stop with fallback to /api/dev-server/stop (legacy compatibility). */ export async function stopDevServerById(id: string, projectId?: string): Promise { try { return await api(withProjectId(`/devserver/${encodeURIComponent(id)}/stop`, projectId), { method: "POST", }); } catch { // Fallback: use legacy stop endpoint const legacy = await stopDevServer(projectId); return { config: { id: legacy.id ?? id, name: legacy.name ?? "Dev Server", command: legacy.command ?? "", cwd: legacy.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; } } /** * Restart a specific dev server by ID. * Targets /api/devserver/:id/restart with fallback to /api/dev-server/restart (legacy compatibility). */ export async function restartDevServerById(id: string, projectId?: string): Promise { try { return await api(withProjectId(`/devserver/${encodeURIComponent(id)}/restart`, projectId), { method: "POST", }); } catch { // Fallback: use legacy restart endpoint const legacy = await restartDevServer(projectId); return { config: { id: legacy.id ?? id, name: legacy.name ?? "Dev Server", command: legacy.command ?? "", cwd: legacy.cwd ?? ".", }, status: legacy.status, runtime: legacy.pid ? { pid: legacy.pid, startedAt: legacy.startedAt ?? new Date().toISOString(), exitCode: legacy.exitCode ?? undefined, previewUrl: legacy.previewUrl, } : undefined, previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined, logHistory: (legacy.logs ?? []).map((text) => ({ timestamp: new Date().toISOString(), stream: text.startsWith("[stderr]") ? "stderr" : "stdout", text: text.replace(/^\[stderr\]\s*/, ""), })), }; } } /** * Delete a specific dev server by ID. * Targets /api/devserver/:id with fallback (no legacy equivalent). */ export async function deleteDevServer(id: string, projectId?: string): Promise { try { await api(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId), { method: "DELETE", }); } catch { // No fallback for delete in legacy API (single-server model) // Silently ignore - deletion may not be supported in legacy mode } } /** * Fetch logs for a specific dev server by ID. * Targets /api/devserver/:id/logs with fallback to /api/dev-server/logs/history (legacy compatibility). */ export async function fetchDevServerLogs( id: string, opts: FetchDevServerLogsOptions = {}, projectId?: string, ): Promise<{ lines: DevServerLogEntry[]; totalLines: number }> { const query = new URLSearchParams(); if (typeof opts.maxLines === "number" && Number.isFinite(opts.maxLines)) { query.set("maxLines", String(Math.max(1, Math.floor(opts.maxLines)))); } if (typeof opts.offset === "number" && Number.isFinite(opts.offset)) { query.set("offset", String(Math.max(0, Math.floor(opts.offset)))); } if (typeof opts.lastEventId === "number" && Number.isFinite(opts.lastEventId)) { query.set("lastEventId", String(Math.max(0, Math.floor(opts.lastEventId)))); } const suffix = query.size > 0 ? `?${query.toString()}` : ""; try { const response = await api( withProjectId(`/devserver/${encodeURIComponent(id)}/logs${suffix}`, projectId), ); return { lines: response.lines ?? [], totalLines: response.totalLines ?? response.lines?.length ?? 0, }; } catch { // Fallback: use legacy log history endpoint try { const response = await fetchDevServerLogHistory(opts, projectId); return { lines: response.lines.map((entry) => ({ timestamp: entry.timestamp, stream: entry.stream, text: entry.text, })), totalLines: response.totalLines, }; } catch { return { lines: [], totalLines: 0 }; } } } /** * Fetch preview URL for a specific dev server by ID. * Targets /api/devserver/:id/preview with fallback to /api/dev-server/status (legacy compatibility). */ export async function fetchDevServerPreview(id: string, projectId?: string): Promise { try { const response = await api( withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId), ); return { url: response.url ?? null, source: (response.source as DevServerPreviewResponse["source"]) ?? null, }; } catch { // Fallback: use legacy status endpoint try { const legacy = await fetchDevServerStatus(projectId); return { url: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null, source: legacy.manualUrl ? "manual" : "auto", }; } catch { return { url: null, source: null }; } } } /** * Set preview URL for a specific dev server by ID. * Targets /api/devserver/:id/preview with fallback to /api/dev-server/preview-url (legacy compatibility). */ export async function setDevServerPreviewUrlById( id: string, url: string | null, projectId?: string, ): Promise { try { const response = await api( withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId), { method: "POST", body: JSON.stringify({ url }), }, ); return { url: response.url ?? null, source: (response.source as DevServerPreviewResponse["source"]) ?? null, }; } catch { // Fallback: use legacy preview URL endpoint const legacy = await setDevServerPreviewUrl({ url }, projectId); return { url: legacy.previewUrl ?? legacy.manualUrl ?? null, source: "manual", }; } } /** * Detect available dev server commands. * Targets /api/devserver/detect with fallback to /api/dev-server/detect (legacy compatibility). */ export async function detectDevServerCommands(projectId?: string): Promise { try { const response = await api(withProjectId("/devserver/detect", projectId)); return response.candidates ?? []; } catch { // Fallback: use legacy detect endpoint try { const legacy = await fetchDevServerCandidates(projectId); return legacy.map((candidate) => ({ name: candidate.name, command: candidate.command, cwd: candidate.cwd, scriptName: candidate.scriptName, packagePath: candidate.packagePath, })); } catch { return []; } } } /** * Get the SSE stream URL for a specific dev server session's logs. * Targets /api/devserver/:id/logs/stream with fallback to /api/dev-server/logs/stream (legacy compatibility). */ export function getDevServerSessionLogsStreamUrl(id: string, projectId?: string): string { // Try new session-scoped endpoint first return buildApiUrl(withProjectId(`/devserver/${encodeURIComponent(id)}/logs/stream`, projectId)); } function startKeepAlive( sessionId: string, projectId?: string, intervalMs = 25_000, ): { stop: () => void } { const timer = setInterval(() => { void pingSession(sessionId, projectId).catch(() => { // Best-effort keepalive: ignore failures so streams remain active. }); }, intervalMs); return { stop: () => { clearInterval(timer); }, }; } /** Get the SSE stream URL for a planning session */ export function getPlanningStreamUrl(sessionId: string, projectId?: string): string { return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId)); } /** Connect to planning session SSE stream and handle events * * Returns an object with: * - close: function to close the connection */ export function connectPlanningStream( sessionId: string, projectId: string | undefined, handlers: { onThinking?: (data: string) => void; onQuestion?: (data: PlanningQuestion) => void; onSummary?: (data: PlanningSummary) => void; onError?: (data: string) => void; onComplete?: () => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { const url = getPlanningStreamUrl(sessionId, projectId); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; const resilient = createResilientEventSource( url, { onOpen: () => { stopKeepAlive(); keepAlive = startKeepAlive(sessionId, projectId); }, onMessage: (event) => { if (event.data.startsWith(":")) return; }, events: { thinking: (event) => { try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); } }, question: (event) => { try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch (err) { console.error("[planning] Failed to parse question event:", err); } }, summary: (event) => { try { handlers.onSummary?.(JSON.parse(event.data) as PlanningSummary); } catch (err) { console.error("[planning] Failed to parse summary event:", err); } }, error: (event) => { try { const parsed = JSON.parse(event.data); handlers.onError?.(parsed.message || parsed); } catch { handlers.onError?.(event.data || "Stream error"); } connection?.close(); }, complete: () => { handlers.onComplete?.(); connection?.close(); }, }, }, { maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { stopKeepAlive(); handlers.onError?.(message); }, }, ); connection = { close: () => { stopKeepAlive(); resilient.close(); }, isConnected: resilient.isConnected, }; return connection; } // ── Automation / Scheduled Tasks ────────────────────────────────── /** * Options for scheduling scope (global vs project-scoped automations/routines). * When scope is "project", projectId must be provided. */ export type SchedulingScopeOptions = { /** Scope for scheduling operations: "global" or "project". Defaults to "project" on the server. */ scope?: "global" | "project"; /** Project ID required when scope is "project". */ projectId?: string; }; /** * Build URL suffix with scope and projectId query params. * Mirrors the backend's parseScopeParam logic: scope goes in query param. */ function withSchedulingScope(path: string, options?: SchedulingScopeOptions): string { const params = new URLSearchParams(); if (options?.scope) { params.set("scope", options.scope); } if (options?.projectId) { params.set("projectId", options.projectId); } const suffix = params.toString(); if (!suffix) return path; return `${path}?${suffix}`; } /** Response from the manual run trigger endpoint. */ export interface AutomationRunResponse { schedule: ScheduledTask; result: AutomationRunResult; } export function fetchAutomations(options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope("/automations", options)); } export function fetchAutomation(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/automations/${id}`, options)); } export function createAutomation(input: ScheduledTaskCreateInput, options?: SchedulingScopeOptions): Promise { // Forward all input fields including scope metadata (scope may be set on input or in options) return api(withSchedulingScope("/automations", options), { method: "POST", body: JSON.stringify(input), }); } export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput, options?: SchedulingScopeOptions): Promise { // Forward all update fields including scope metadata return api(withSchedulingScope(`/automations/${id}`, options), { method: "PATCH", body: JSON.stringify(updates), }); } export async function deleteAutomation(id: string, options?: SchedulingScopeOptions): Promise { await api(withSchedulingScope(`/automations/${id}`, options), { method: "DELETE", }); } export function runAutomation(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/automations/${id}/run`, options), { method: "POST", }); } export function toggleAutomation(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/automations/${id}/toggle`, options), { method: "POST", }); } export function reorderAutomationSteps(id: string, stepIds: string[], options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/automations/${id}/steps/reorder`, options), { method: "POST", body: JSON.stringify({ stepIds }), }); } // ── Routines API ──────────────────────────────────────────────── export interface RoutineRunResponse { routine: Routine; result: RoutineExecutionResult; } export function fetchRoutines(options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope("/routines", options)); } export function fetchRoutine(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}`, options)); } export function createRoutine(input: RoutineCreateInput, options?: SchedulingScopeOptions): Promise { // Forward all input fields including scope metadata return api(withSchedulingScope("/routines", options), { method: "POST", body: JSON.stringify(input), }); } export function updateRoutine(id: string, updates: RoutineUpdateInput, options?: SchedulingScopeOptions): Promise { // Forward all update fields including scope metadata return api(withSchedulingScope(`/routines/${id}`, options), { method: "PATCH", body: JSON.stringify(updates), }); } export async function deleteRoutine(id: string, options?: SchedulingScopeOptions): Promise { await api(withSchedulingScope(`/routines/${id}`, options), { method: "DELETE", }); } export function runRoutine(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}/trigger`, options), { method: "POST", }); } export function fetchRoutineRuns(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}/runs`, options)); } export function triggerRoutineWebhook(id: string, payload?: Record, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}/webhook`, options), { method: "POST", body: payload ? JSON.stringify(payload) : undefined, }); } // ── Activity Log API ──────────────────────────────────────────── /** Re-export ActivityLogEntry type from core for convenience */ export type { ActivityLogEntry, ActivityEventType } from "@fusion/core"; /** Fetch activity log entries */ export function fetchActivityLog(options?: { limit?: number; since?: string; type?: ActivityEventType; projectId?: string }): Promise { const search = new URLSearchParams(); if (options?.limit !== undefined) search.set("limit", String(options.limit)); if (options?.since !== undefined) search.set("since", options.since); if (options?.type !== undefined) search.set("type", options.type); if (options?.projectId) search.set("projectId", options.projectId); const suffix = search.size > 0 ? `?${search.toString()}` : ""; return api(`/activity${suffix}`); } /** Clear all activity log entries */ export function clearActivityLog(projectId?: string): Promise<{ success: boolean }> { const path = withProjectId("/activity", projectId); return api<{ success: boolean }>(path, { method: "DELETE" }); } // ── Workflow Steps ───────────────────────────────────────────────────── /** Fetch all workflow step definitions */ export function fetchWorkflowSteps(projectId?: string): Promise { return api(withProjectId("/workflow-steps", projectId)); } /** Create a new workflow step */ export function createWorkflowStep(input: WorkflowStepInput, projectId?: string): Promise { return api(withProjectId("/workflow-steps", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update a workflow step */ export function updateWorkflowStep(id: string, updates: Partial, projectId?: string): Promise { return api(withProjectId(`/workflow-steps/${id}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete a workflow step */ export function deleteWorkflowStep(id: string, projectId?: string): Promise { return api(withProjectId(`/workflow-steps/${id}`, projectId), { method: "DELETE" }); } /** Refine a workflow step's prompt using AI */ export function refineWorkflowStepPrompt(id: string, projectId?: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> { return api<{ prompt: string; workflowStep: WorkflowStep }>(withProjectId(`/workflow-steps/${id}/refine`, projectId), { method: "POST", }); } /** Fetch workflow step results for a task */ export function fetchWorkflowResults(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow-results`, projectId)); } // ── Workflow Step Templates ────────────────────────────────────────────── /** Re-export WorkflowStepTemplate type from core */ export type { WorkflowStepTemplate } from "@fusion/core"; /** Fetch all built-in workflow step templates */ export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@fusion/core").WorkflowStepTemplate[] }> { return api<{ templates: import("@fusion/core").WorkflowStepTemplate[] }>("/workflow-step-templates"); } /** Create a workflow step from a built-in template */ export function createWorkflowStepFromTemplate(templateId: string, projectId?: string): Promise { return api(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), { method: "POST", }); } // ── Scripts API ──────────────────────────────────────────────────────── /** Script entry returned from the API */ export interface ScriptEntry { name: string; command: string; } /** Result of running a script via POST /api/scripts/:name/run */ export interface ScriptRunResult { sessionId: string; command: string; } /** Fetch all saved scripts from project settings */ export function fetchScripts(projectId?: string): Promise> { return api>(withProjectId("/scripts", projectId)); } /** Add or update a script */ export function addScript(name: string, command: string, projectId?: string): Promise { return api(withProjectId("/scripts", projectId), { method: "POST", body: JSON.stringify({ name, command }), }); } /** Remove a script by name */ export function removeScript(name: string, projectId?: string): Promise { return api(withProjectId(`/scripts/${encodeURIComponent(name)}`, projectId), { method: "DELETE" }); } /** Run a saved script by name */ export function runScript(name: string, args?: string[], projectId?: string): Promise { return api(withProjectId(`/scripts/${encodeURIComponent(name)}/run`, projectId), { method: "POST", body: JSON.stringify({ args }), }); } // ── AI Text Refinement API ──────────────────────────────────────────── /** Refinement types for AI text refinement */ export type RefinementType = "clarify" | "add-details" | "expand" | "simplify"; /** Response from text refinement endpoint */ export interface RefineTextResponse { refined: string; } /** * Refine task description text using AI. * @param text - The text to refine (1-2000 characters) * @param type - The refinement type: clarify, add-details, expand, or simplify * @param projectId - Optional project ID for scoped settings resolution * @returns The refined text * @throws Error with message for rate limit (429), invalid type (422), validation (400), or server errors */ export async function refineText(text: string, type: RefinementType, projectId?: string): Promise { const response = await api(withProjectId("/ai/refine-text", projectId), { method: "POST", body: JSON.stringify({ text, type }), }); return response.refined; } /** * Error messages for refineText failures (to use with toast notifications). */ export const REFINE_ERROR_MESSAGES = { /** Rate limit exceeded (429) */ RATE_LIMIT: "Too many refinement requests. Please wait an hour.", /** Invalid refinement type (422) */ INVALID_TYPE: "Invalid refinement option selected.", /** Network or server errors */ NETWORK: "Failed to refine text. Please try again.", } as const; /** * Get user-friendly error message for a refineText error. * @param error - The error thrown by refineText * @returns A user-friendly error message suitable for toast display */ export function getRefineErrorMessage(error: unknown): string { if (!(error instanceof Error)) { return REFINE_ERROR_MESSAGES.NETWORK; } const message = error.message.toLowerCase(); // Rate limit errors (429) if (message.includes("rate limit") || message.includes("429")) { return REFINE_ERROR_MESSAGES.RATE_LIMIT; } // Invalid type errors (422) if (message.includes("invalid") && message.includes("type")) { return REFINE_ERROR_MESSAGES.INVALID_TYPE; } // Text validation errors (400) - pass through from backend if ( message.startsWith("text must") || message.includes("text is required") || message.includes("type is required") ) { return error.message; } // Default network/server error return REFINE_ERROR_MESSAGES.NETWORK; } export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), { method: "POST", body: JSON.stringify({ description }), }); } export function retrySubtaskSession( sessionId: string, projectId?: string, tabId?: string, ): Promise<{ success: boolean; sessionId: string }> { return api<{ success: boolean; sessionId: string }>( withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId), { method: "POST", ...(tabId ? { body: JSON.stringify({ tabId }) } : {}), }, ); } export function getSubtaskStreamUrl(sessionId: string, projectId?: string): string { return buildApiUrl(withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/stream`, projectId)); } export function connectSubtaskStream( sessionId: string, projectId: string | undefined, handlers: { onThinking?: (data: string) => void; onSubtasks?: (data: SubtaskItem[]) => void; onError?: (data: string) => void; onComplete?: () => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; const resilient = createResilientEventSource( getSubtaskStreamUrl(sessionId, projectId), { onOpen: () => { stopKeepAlive(); keepAlive = startKeepAlive(sessionId, projectId); }, events: { thinking: (event) => { try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); } }, subtasks: (event) => { try { handlers.onSubtasks?.(JSON.parse(event.data) as SubtaskItem[]); } catch (err) { console.error("[subtasks] Failed to parse subtasks event:", err); } }, error: (event) => { try { const parsedData = JSON.parse(event.data); const errorMessage = typeof parsedData === "string" && parsedData.length > 0 ? parsedData : null; handlers.onError?.(errorMessage || "Stream error"); } catch { handlers.onError?.("Stream error"); } connection?.close(); }, complete: () => { handlers.onComplete?.(); connection?.close(); }, }, }, { maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { stopKeepAlive(); handlers.onError?.(message); }, }, ); connection = { close: () => { stopKeepAlive(); resilient.close(); }, isConnected: resilient.isConnected, }; return connection; } export function createTasksFromBreakdown( sessionId: string, subtasks: SubtaskItem[], parentTaskId?: string, projectId?: string, ): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> { return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), { method: "POST", body: JSON.stringify({ sessionId, parentTaskId, subtasks: subtasks.map((subtask) => ({ tempId: subtask.id, title: subtask.title, description: subtask.description, size: subtask.suggestedSize, dependsOn: subtask.dependsOn, })), }), }); } export function cancelSubtaskBreakdown(sessionId: string, projectId?: string, tabId?: string): Promise { return api(withProjectId("/subtasks/cancel", projectId), { method: "POST", body: JSON.stringify({ sessionId, tabId }), }); } // ── Agent API ──────────────────────────────────────────────────────────── import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus, } from "@fusion/core"; export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus }; function withProjectId(path: string, projectId?: string): string { if (!projectId) return path; const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}projectId=${encodeURIComponent(projectId)}`; } /** * Rewrite a path to route through the node proxy when viewing a remote node. * When nodeId is provided and differs from localNodeId (i.e., it's a remote node), * rewrites the path from `/tasks` to `/proxy/${encodeURIComponent(nodeId)}/tasks`. * When nodeId is undefined or matches localNodeId, returns the path unchanged. */ export function withNodeId(path: string, nodeId?: string, localNodeId?: string): string { if (!nodeId || nodeId === localNodeId) return path; // Rewrite path to proxy endpoint: /tasks -> /proxy/:nodeId/tasks // Strip leading /api prefix if present since proxyApi adds it const apiPrefix = "/api"; const pathWithoutPrefix = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) : path; return `/proxy/${encodeURIComponent(nodeId)}${pathWithoutPrefix}`; } /** * Make an API request, optionally routing through the node proxy for remote nodes. * When nodeId is provided and differs from localNodeId, the request is routed * through /api/proxy/:nodeId/... instead of directly. */ export function proxyApi(path: string, opts?: RequestInit & { nodeId?: string; localNodeId?: string }): Promise { // Extract nodeId/localNodeId from opts before passing to api() const { nodeId, localNodeId, ...fetchOpts } = opts ?? {}; const resolvedPath = withNodeId(path, nodeId, localNodeId); return api(resolvedPath, fetchOpts); } /** Fetch all agents, optionally filtered by state or role */ export function fetchAgents( filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (filter?.state) params.set("state", filter.state); if (filter?.role) params.set("role", filter.role); if (filter?.includeEphemeral === true) params.set("includeEphemeral", "true"); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents${query}`); } /** Fetch a single agent with heartbeat history */ export function fetchAgent(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId)); } /** Create a new agent */ export function createAgent(input: AgentCreateInput, projectId?: string): Promise { return api(withProjectId("/agents", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update an agent */ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Update agent custom instructions */ export function updateAgentInstructions( agentId: string, instructions: { instructionsPath?: string; instructionsText?: string }, projectId?: string, ): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/instructions`, projectId), { method: "PATCH", body: JSON.stringify(instructions), }); } /** Fetch agent soul/personality text */ export function fetchAgentSoul(agentId: string, projectId?: string): Promise<{ soul: string | null }> { return api<{ soul: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId)); } /** Update agent soul/personality text */ export function updateAgentSoul(agentId: string, soul: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId), { method: "PATCH", body: JSON.stringify({ soul }), }); } /** Fetch per-agent memory text */ export function fetchAgentMemory(agentId: string, projectId?: string): Promise<{ memory: string | null }> { return api<{ memory: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId)); } /** Update per-agent memory text */ export function updateAgentMemory(agentId: string, memory: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId), { method: "PATCH", body: JSON.stringify({ memory }), }); } /** List file-based memory entries for a specific agent */ export function fetchAgentMemoryFiles(agentId: string, projectId?: string): Promise<{ files: MemoryFileInfo[] }> { return api<{ files: MemoryFileInfo[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/files`, projectId)); } /** Read one file-based memory entry for a specific agent */ export function fetchAgentMemoryFile(agentId: string, path: string, projectId?: string): Promise<{ path: string; content: string }> { const query = `path=${encodeURIComponent(path)}`; return api<{ path: string; content: string }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file?${query}`, projectId)); } /** Save one file-based memory entry for a specific agent */ export function saveAgentMemoryFile(agentId: string, path: string, content: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file`, projectId), { method: "PUT", body: JSON.stringify({ path, content }), }); } /** Update an agent's state */ export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), { method: "POST", body: JSON.stringify({ state }), }); } /** Delete an agent */ export function deleteAgent(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), { method: "DELETE", }); } /** Record a heartbeat for an agent */ export function recordAgentHeartbeat( agentId: string, status: "ok" | "missed" | "recovered" = "ok", projectId?: string, ): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/heartbeat`, projectId), { method: "POST", body: JSON.stringify({ status }), }); } /** Fetch heartbeat history for an agent */ export function fetchAgentHeartbeats(agentId: string, limit?: number, projectId?: string): Promise { const params = new URLSearchParams(); if (limit !== undefined) params.set("limit", String(limit)); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`); } /** Fetch heartbeat runs for an agent */ export function fetchAgentRuns(agentId: string, limit?: number, projectId?: string): Promise { const params = new URLSearchParams(); if (limit !== undefined) params.set("limit", String(limit)); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents/${encodeURIComponent(agentId)}/runs${query}`); } /** Fetch a single heartbeat run detail */ export function fetchAgentRunDetail(agentId: string, runId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId)); } /** Fetch agent logs for a specific run's time window */ export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId)); } /** Manually start a heartbeat run for an agent */ export function startAgentRun( agentId: string, projectId?: string, options?: { source?: HeartbeatInvocationSource; triggerDetail?: string }, ): Promise { const source = options?.source ?? "manual"; const triggerDetail = options?.triggerDetail ?? "Agent activated via dashboard"; return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), { method: "POST", body: JSON.stringify({ source, triggerDetail }), }); } /** Stop an active heartbeat run for an agent */ export function stopAgentRun( agentId: string, projectId?: string, ): Promise<{ ok: boolean; runId?: string; message?: string }> { return api<{ ok: boolean; runId?: string; message?: string }>( withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/stop`, projectId), { method: "POST", }, ); } // ── Run-Audit & Timeline API ──────────────────────────────────────────────── /** Valid domain filters for run-audit queries. */ export type RunAuditDomainFilter = "database" | "git" | "filesystem"; /** Filter options for run-audit queries. */ export interface RunAuditFilters { /** Filter by task ID */ taskId?: string; /** Filter by domain category */ domain?: RunAuditDomainFilter; /** Start of time range (inclusive, ISO-8601) */ startTime?: string; /** End of time range (inclusive, ISO-8601) */ endTime?: string; /** Maximum number of events to return */ limit?: number; } /** Normalized run-audit event for UI consumption. */ export interface NormalizedRunAuditEvent { id: string; timestamp: string; taskId?: string; domain: "database" | "git" | "filesystem"; mutationType: string; target: string; summary: string; metadata?: Record; } /** Response shape for run-audit endpoint. */ export interface RunAuditResponse { runId: string; events: NormalizedRunAuditEvent[]; filters: { taskId?: string; domain?: RunAuditDomainFilter; startTime?: string; endTime?: string; }; totalCount: number; hasMore: boolean; } /** Unified timeline entry that can represent either an audit event or an agent log entry. */ export interface TimelineEntry { timestamp: string; type: "audit" | "log"; sortKey: string; audit?: NormalizedRunAuditEvent; log?: AgentLogEntry; } /** Response shape for run-timeline endpoint. */ export interface RunTimelineResponse { run: { id: string; agentId: string; startedAt: string; endedAt?: string; status: string; taskId?: string; }; auditByDomain: { database: NormalizedRunAuditEvent[]; git: NormalizedRunAuditEvent[]; filesystem: NormalizedRunAuditEvent[]; }; counts: { auditEvents: number; logEntries: number; }; timeline: TimelineEntry[]; } /** * Fetch normalized run-audit events for a specific agent run. * * @param agentId - The agent ID * @param runId - The run ID * @param filters - Optional filter parameters * @param projectId - Optional project ID for multi-project workspaces * @returns Promise resolving to RunAuditResponse with normalized events * @throws Error if runId is blank or whitespace-only */ export function fetchAgentRunAudit( agentId: string, runId: string, filters?: RunAuditFilters, projectId?: string, ): Promise { // Validate runId before making API call if (!runId || runId.trim().length === 0) { throw new Error("runId is required"); } const params = new URLSearchParams(); if (filters?.taskId) params.set("taskId", filters.taskId); if (filters?.domain) params.set("domain", filters.domain); if (filters?.startTime) params.set("startTime", filters.startTime); if (filters?.endTime) params.set("endTime", filters.endTime); if (filters?.limit !== undefined) params.set("limit", String(filters.limit)); const query = params.size > 0 ? `?${params.toString()}` : ""; return api( withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/audit${query}`, projectId), ); } /** * Fetch a correlated timeline combining run-audit events and agent logs for a specific run. * * @param agentId - The agent ID * @param runId - The run ID * @param options - Optional parameters * @param options.taskId - Override task ID for audit filtering (defaults to run's contextSnapshot.taskId) * @param options.domain - Filter audit events by domain * @param options.startTime - Start of time range (ISO-8601) * @param options.endTime - End of time range (ISO-8601) * @param options.includeLogs - Whether to include agent logs (default true) * @param options.limit - Maximum audit events to return * @param projectId - Optional project ID for multi-project workspaces * @returns Promise resolving to RunTimelineResponse with merged timeline * @throws Error if runId is blank or whitespace-only */ export function fetchAgentRunTimeline( agentId: string, runId: string, options?: { taskId?: string; domain?: RunAuditDomainFilter; startTime?: string; endTime?: string; includeLogs?: boolean; limit?: number; }, projectId?: string, ): Promise { // Validate runId before making API call if (!runId || runId.trim().length === 0) { throw new Error("runId is required"); } const params = new URLSearchParams(); if (options?.taskId) params.set("taskId", options.taskId); if (options?.domain) params.set("domain", options.domain); if (options?.startTime) params.set("startTime", options.startTime); if (options?.endTime) params.set("endTime", options.endTime); if (options?.includeLogs !== undefined) params.set("includeLogs", String(options.includeLogs)); if (options?.limit !== undefined) params.set("limit", String(options.limit)); const query = params.size > 0 ? `?${params.toString()}` : ""; return api( withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/timeline${query}`, projectId), ); } /** Fetch aggregate agent stats */ export function fetchAgentStats(projectId?: string): Promise { return api(withProjectId("/agents/stats", projectId)); } /** Fetch the chain of command for an agent (self → manager → grand-manager → ...) */ export function fetchChainOfCommand(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/chain-of-command`, projectId)); } /** Fetch the full org tree as nested nodes */ export function fetchOrgTree(projectId?: string, options?: { includeEphemeral?: boolean }): Promise { const params = new URLSearchParams(); if (projectId) params.set("projectId", projectId); if (options?.includeEphemeral) params.set("includeEphemeral", "true"); const query = params.toString(); return api(`/agents/org-tree${query ? `?${query}` : ""}`); } /** Resolve an agent by shortname or ID */ export function resolveAgent(shortname: string, projectId?: string): Promise<{ agent: Agent }> { return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId)); } /** Fetch employees (agents that report to a given parent agent) */ export function fetchAgentChildren(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => { // Return empty array for 404 (agent may have been deleted) if (err.message.includes("not found")) return []; throw err; }); } /** Alias for fetchAgentChildren with employee-focused naming */ export const fetchAgentEmployees = fetchAgentChildren; /** Assign or unassign a task to an explicit agent */ export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), { method: "PATCH", body: JSON.stringify({ agentId }), }); } /** Assign or unassign a task to a user (for review handoff) */ export function assignTaskToUser(taskId: string, userId: string | null, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign-user`, projectId), { method: "PATCH", body: JSON.stringify({ userId }), }); } /** Accept review - clear assignee and awaiting-user-review status, keep in in-review */ export function acceptTaskReview(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/accept-review`, projectId), { method: "POST", }); } /** Return task to agent - clear assignee and status, move to todo */ export function returnTaskToAgent(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), { method: "POST", }); } /** Fetch tasks explicitly assigned to an agent */ export function fetchAgentTasks(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/tasks`, projectId)); } // ── Agent Import API ──────────────────────────────────────────────────────── /** Company entry from companies.sh catalog */ export interface CompanyEntry { slug: string; name: string; tagline?: string; repo?: string; website?: string; installs?: number; } /** Response from companies.sh catalog API */ export interface CompaniesCatalogResponse { companies: CompanyEntry[]; error?: string; } /** Result of importing agents from an Agent Companies source */ export interface AgentImportResult { companyName?: string; companySlug?: string; agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>; /** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */ created: string[] | Array<{ id: string; name: string }>; skipped: string[]; errors: Array<{ name: string; error: string }>; dryRun?: boolean; } /** * Fetch companies from companies.sh catalog. * Returns both companies and optional error message for proper error surfacing. */ export function fetchCompanies(): Promise { return api("/agents/companies"); } /** * Import agents from an Agent Companies source via the API. * Uses dryRun for preview, then actual import. * * Supports four input modes: * - { manifest: string } - raw AGENTS.md content * - { source: string } - server directory path * - { agents: unknown[] } - parsed agent manifests * - { importSource: "companies.sh", companySlug: string } - companies.sh catalog entry */ export function importAgents( input: | { manifest: string } | { source: string } | { agents: unknown[] } | { importSource: "companies.sh"; companySlug: string }, options?: { dryRun?: boolean; skipExisting?: boolean }, projectId?: string, ): Promise { return api(withProjectId("/agents/import", projectId), { method: "POST", body: JSON.stringify({ ...input, dryRun: options?.dryRun ?? false, skipExisting: options?.skipExisting ?? true, }), }); } // ── Agent Generation API ──────────────────────────────────────────────────── /** Generated agent specification returned by the AI */ export interface AgentGenerationSpec { /** Display name for the agent */ title: string; /** Single emoji icon */ icon: string; /** Agent capability/role */ role: string; /** Brief description of the agent's purpose */ description: string; /** Detailed system prompt in markdown */ systemPrompt: string; /** Suggested thinking level */ thinkingLevel: "off" | "minimal" | "low" | "medium" | "high"; /** Suggested max turns (1-500) */ maxTurns: number; } /** State of an agent generation session */ export interface AgentGenerationSession { id: string; roleDescription: string; spec?: AgentGenerationSpec; createdAt: string; updatedAt: string; } /** Start an agent generation session with a role description */ export function startAgentGeneration(role: string, projectId?: string): Promise<{ sessionId: string; roleDescription: string }> { return api<{ sessionId: string; roleDescription: string }>(withProjectId("/agents/generate/start", projectId), { method: "POST", body: JSON.stringify({ role }), }); } /** Generate the agent specification for an existing session */ export function generateAgentSpec(sessionId: string, projectId?: string): Promise<{ spec: AgentGenerationSpec }> { return api<{ spec: AgentGenerationSpec }>(withProjectId("/agents/generate/spec", projectId), { method: "POST", body: JSON.stringify({ sessionId }), }); } /** Get the current state of an agent generation session */ export function getAgentGenerationSession(sessionId: string, projectId?: string): Promise<{ session: AgentGenerationSession }> { return api<{ session: AgentGenerationSession }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId)); } /** Cancel and clean up an agent generation session */ export function cancelAgentGeneration(sessionId: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId), { method: "DELETE", }); } // --- Backup API --- /** Backup metadata from the API */ export interface BackupInfo { filename: string; createdAt: string; size: number; path: string; } /** Result of listing backups */ export interface BackupListResponse { backups: BackupInfo[]; count: number; totalSize: number; } /** Result of creating a backup */ export interface BackupCreateResponse { success: boolean; backupPath?: string; output?: string; deletedCount?: number; error?: string; } /** Fetch all database backups */ export function fetchBackups(projectId?: string): Promise { return api(withProjectId("/backups", projectId)); } /** Create a new database backup immediately */ export function createBackup(projectId?: string): Promise { return api(withProjectId("/backups", projectId), { method: "POST" }); } // --- Settings Export/Import API --- /** Exported settings data structure */ export interface SettingsExportData { version: 1; exportedAt: string; source?: string; global?: GlobalSettings; project?: Partial; } /** Result of importing settings */ export interface SettingsImportResponse { success: boolean; globalCount: number; projectCount: number; error?: string; } /** Export settings as JSON */ export function exportSettings(scope?: 'global' | 'project' | 'both', projectId?: string): Promise { const path = withProjectId("/settings/export", projectId); const scopedPath = scope ? `${path}${path.includes("?") ? "&" : "?"}scope=${encodeURIComponent(scope)}` : path; return api(scopedPath); } /** Import settings from JSON data */ export function importSettings( data: SettingsExportData, options?: { scope?: 'global' | 'project' | 'both'; merge?: boolean }, projectId?: string ): Promise { return api(withProjectId("/settings/import", projectId), { method: "POST", body: JSON.stringify({ data, scope: options?.scope ?? "both", merge: options?.merge ?? true, }), }); } // --- AI Summarization API --- /** Response from title summarization endpoint */ export interface SummarizeTitleResponse { title: string; } /** Summarize a task description into a concise title using AI. * @param description - The task description to summarize (must be 201-2000 chars) * @param provider - Optional AI model provider (e.g., "anthropic") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") * @param projectId - Optional project ID for scoped settings resolution * @returns The generated title (guaranteed ≤60 characters) * @throws Error with descriptive message for 400/429/503 errors */ export async function summarizeTitle( description: string, provider?: string, modelId?: string, projectId?: string ): Promise { const url = projectId ? `/api/ai/summarize-title?projectId=${encodeURIComponent(projectId)}` : "/api/ai/summarize-title"; const res = await fetch(url, { method: "POST", headers: withTokenHeader({ "Content-Type": "application/json" }), body: JSON.stringify({ description, provider, modelId }), }); const contentType = res.headers.get("content-type") ?? ""; const bodyText = await res.text(); const isJson = contentType.includes("application/json"); if (!isJson) { throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`); } const data = JSON.parse(bodyText) as { title?: string; error?: string }; if (!res.ok) { const errorMessage = data.error || "Request failed"; if (res.status === 400) { throw new Error(`Invalid request: ${errorMessage}`); } else if (res.status === 429) { throw new Error(`Rate limit exceeded: ${errorMessage}`); } else if (res.status === 503) { throw new Error(`AI service temporarily unavailable: ${errorMessage}`); } else { throw new Error(errorMessage); } } if (!data.title) { throw new Error("API returned empty title"); } return data.title; } // ── Project Management API (Multi-Project Support) ─────────────────────── /** Project information returned by project endpoints */ export interface ProjectInfo { id: string; name: string; path: string; status: "active" | "paused" | "errored" | "initializing"; isolationMode: "in-process" | "child-process"; nodeId?: string; createdAt: string; updatedAt: string; lastActivityAt?: string; } /** Project health metrics */ export interface ProjectHealth { projectId: string; status: "active" | "paused" | "errored" | "initializing"; activeTaskCount: number; inFlightAgentCount: number; lastActivityAt?: string; lastErrorAt?: string; lastErrorMessage?: string; totalTasksCompleted: number; totalTasksFailed: number; averageTaskDurationMs?: number; updatedAt: string; } /** Executor state values */ export type ExecutorState = "idle" | "running" | "paused"; /** Aggregated executor statistics for the status bar. * * Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount) * are derived client-side from the same tasks array shared with the board, ensuring * the footer counts always match the column counts displayed on screen. * The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and * lastActivityAt from the activity log. * * The executorState is derived from: * - "idle": globalPause is true OR (enginePaused is true AND runningTaskCount is 0) * - "paused": enginePaused is true AND runningTaskCount > 0 * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 */ export interface ExecutorStats { /** Number of tasks currently in "in-progress" column */ runningTaskCount: number; /** Number of tasks with blockedBy field set (waiting on file overlap) */ blockedTaskCount: number; /** Number of "in-progress" tasks with no activity for > 10 minutes */ stuckTaskCount: number; /** Number of tasks in "todo" column */ queuedTaskCount: number; /** Number of tasks in "in-review" column */ inReviewCount: number; /** Derived executor state: "idle", "running", or "paused" */ executorState: ExecutorState; /** Maximum concurrent tasks allowed from settings */ maxConcurrent: number; /** ISO timestamp of most recent task event from activity log */ lastActivityAt?: string; } /** Unified activity feed entry */ export interface ActivityFeedEntry { id: string; timestamp: string; type: "task:created" | "task:moved" | "task:updated" | "task:deleted" | "task:merged" | "task:failed" | "settings:updated"; projectId: string; projectName: string; taskId?: string; taskTitle?: string; details: string; metadata?: Record; } /** Input for creating a new project */ export interface ProjectCreateInput { name: string; path: string; isolationMode?: "in-process" | "child-process"; nodeId?: string; cloneUrl?: string; } /** Node information returned by node endpoints */ export interface NodeInfo { id: NodeConfig["id"]; name: NodeConfig["name"]; type: NodeConfig["type"]; url?: NodeConfig["url"]; apiKey?: NodeConfig["apiKey"]; status: NodeStatus; capabilities?: NodeConfig["capabilities"]; maxConcurrent: NodeConfig["maxConcurrent"]; createdAt: NodeConfig["createdAt"]; updatedAt: NodeConfig["updatedAt"]; } /** Node discovered over local network mDNS/DNS-SD */ export interface DiscoveredNodeInfo { name: string; host: string; port: number; nodeType: "local" | "remote"; nodeId?: string; discoveredAt: string; lastSeenAt: string; } /** Input for creating a new node */ export interface NodeCreateInput { name: string; type: "local" | "remote"; url?: string; apiKey?: string; maxConcurrent?: number; } /** Input for updating an existing node */ export type NodeUpdateInput = Partial> & { status?: NodeStatus; capabilities?: string[]; }; /** Result from a node health check */ export interface NodeHealthCheckResult { nodeId: string; status: NodeStatus; responseTimeMs?: number; error?: string; checkedAt: string; } /** Runtime metrics for a node */ export interface NodeMetrics { nodeId: string; activeTaskCount: number; inFlightAgentCount: number; uptimeMs: number; lastActivityAt?: string; } /** Options for fetching activity feed */ export interface FeedOptions { limit?: number; since?: string; projectId?: string; type?: ActivityFeedEntry["type"]; } /** Global concurrency state across all projects */ export interface GlobalConcurrencyState { globalMaxConcurrent: number; currentlyActive: number; queuedCount: number; projectsActive: Record; } /** First run status response */ export interface FirstRunStatus { hasProjects: boolean; singleProjectPath: string | null; } /** Setup state for first-run wizard */ export interface SetupState { /** The first-run state: fresh-install, setup-wizard, normal-operation */ state: "fresh-install" | "setup-wizard" | "normal-operation"; /** Projects detected on the filesystem (not yet registered) */ detectedProjects: Array<{ path: string; name: string; hasDb: boolean; }>; /** Whether the central database exists */ hasCentralDb: boolean; /** Projects already registered in the central database */ registeredProjects: Array<{ id: string; name: string; path: string; }>; } /** Input for completing setup */ export interface CompleteSetupInput { projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process"; }>; } /** Result of completing setup */ export interface CompleteSetupResult { success: boolean; projectsRegistered: string[]; errors: string[]; } /** Fetch all registered projects */ export function fetchProjects(): Promise { return api("/projects"); } /** Project info with source node metadata (added by server for remote projects) */ export interface ProjectInfoWithSource extends ProjectInfo { /** Name of the source node (added by server for remote projects) */ _sourceNodeName?: string; } /** Fetch all registered projects from all nodes (local + remote) */ export function fetchProjectsAcrossNodes(): Promise { return api("/projects/across-nodes"); } /** * Append a client-side perf measurement to the shared dashboard-perf log on disk. * Used when browser devtools aren't available (e.g. mobile). Best-effort. */ export function reportDashboardPerf(source: string, message: string): void { void api("/_perf/dashboard-load", { method: "POST", body: JSON.stringify({ source, message }), }).catch(() => { // best-effort only }); } /** Fetch all registered nodes */ export function fetchNodes(): Promise { return api("/nodes"); } /** Fetch discovery runtime status and active config. */ export function fetchDiscoveryStatus(): Promise<{ active: boolean; config: DiscoveryConfig | null }> { return api<{ active: boolean; config: DiscoveryConfig | null }>("/discovery/status"); } /** Start local-network discovery service. */ export function startDiscovery(input?: { broadcast?: boolean; listen?: boolean; port?: number; }): Promise<{ success: boolean; config: DiscoveryConfig }> { return api<{ success: boolean; config: DiscoveryConfig }>("/discovery/start", { method: "POST", body: JSON.stringify(input ?? {}), }); } /** Stop local-network discovery service. */ export function stopDiscovery(): Promise<{ success: boolean }> { return api<{ success: boolean }>("/discovery/stop", { method: "POST", body: JSON.stringify({}), }); } /** Fetch currently discovered nodes from mDNS/DNS-SD. */ export function fetchDiscoveredNodes(): Promise { return api("/discovery/nodes"); } /** Register a discovered node into the central node registry. */ export function connectDiscoveredNode(input: { name: string; host: string; port: number; apiKey?: string; }): Promise { return api("/discovery/connect", { method: "POST", body: JSON.stringify(input), }); } /** Register a new node */ export function registerNode(input: NodeCreateInput): Promise { return api("/nodes", { method: "POST", body: JSON.stringify(input), }); } /** Fetch a single node by ID */ export function fetchNode(id: string): Promise { return api(`/nodes/${encodeURIComponent(id)}`); } /** Update an existing node */ export function updateNode(id: string, updates: NodeUpdateInput): Promise { return api(`/nodes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(updates), }); } /** Unregister a node */ export function unregisterNode(id: string): Promise { return api(`/nodes/${encodeURIComponent(id)}`, { method: "DELETE", }); } /** Trigger a node health check */ export async function checkNodeHealth(id: string): Promise { const result = await api & { status: NodeStatus }>(`/nodes/${encodeURIComponent(id)}/health-check`, { method: "POST", }); return { nodeId: result.nodeId ?? id, status: result.status, responseTimeMs: result.responseTimeMs, error: result.error, checkedAt: result.checkedAt ?? new Date().toISOString(), }; } /** Fetch runtime metrics for a node */ export async function fetchNodeMetrics(id: string): Promise { return api(`/nodes/${encodeURIComponent(id)}/metrics`); } /** Fetch full mesh topology state (all nodes with their metrics and known peers) */ export async function fetchMeshState(): Promise { return api("/mesh/state"); } /** Browse directory entries for the directory picker */ export interface BrowseDirectoryResult { currentPath: string; parentPath: string | null; entries: Array<{ name: string; path: string; hasChildren: boolean }>; } export function browseDirectory( path?: string, showHidden?: boolean, nodeId?: string, localNodeId?: string, ): Promise { const effectiveNodeId = nodeId && nodeId !== localNodeId ? nodeId : undefined; const params = new URLSearchParams(); if (path) params.set("path", path); if (showHidden) params.set("showHidden", "true"); if (effectiveNodeId) params.set("nodeId", effectiveNodeId); const token = getAuthToken(); if (token) { params.set("fn_token", token); } const qs = params.toString(); const fullPath = `/browse-directory${qs ? `?${qs}` : ""}`; return api(fullPath); } /** Register a new project */ export function registerProject(input: ProjectCreateInput): Promise { return api("/projects", { method: "POST", body: JSON.stringify(input), }); } /** Unregister a project */ export function unregisterProject(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE", }); } /** Fetch health metrics for a specific project */ export function fetchProjectHealth(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}/health`); } /** Fetch executor statistics for the status bar. * * Returns settings-based values and lastActivityAt. * Counts are derived client-side from the tasks array. */ export function fetchExecutorStats(projectId?: string): Promise<{ globalPause: boolean; enginePaused: boolean; maxConcurrent: number; lastActivityAt?: string; }> { return api<{ globalPause: boolean; enginePaused: boolean; maxConcurrent: number; lastActivityAt?: string; }>(withProjectId("/executor/stats", projectId)); } /** Fetch unified activity feed */ export function fetchActivityFeed(options?: FeedOptions): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.since) params.set("since", options.since); if (options?.projectId) params.set("projectId", options.projectId); if (options?.type) params.set("type", options.type); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/activity-feed${query}`); } /** Pause a project */ export function pauseProject(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}/pause`, { method: "POST", }); } /** Resume a paused project */ export function resumeProject(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}/resume`, { method: "POST", }); } /** Fetch first run status to detect if user needs setup wizard */ export function fetchFirstRunStatus(): Promise { return api("/first-run-status"); } /** Fetch detailed setup state including detected projects */ export function fetchSetupState(): Promise { return api("/setup-state"); } /** Complete first-run setup by registering projects */ export function completeSetup(input: CompleteSetupInput): Promise { return api("/complete-setup", { method: "POST", body: JSON.stringify(input), }); } /** Fetch global concurrency state */ export function fetchGlobalConcurrency(): Promise { return api("/global-concurrency"); } /** Update the system-wide concurrency limit shared across all projects. */ export function updateGlobalConcurrency(input: { globalMaxConcurrent: number; }): Promise { return api("/global-concurrency", { method: "PUT", body: JSON.stringify(input), }); } /** Fetch tasks for a specific project */ export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise { const params = new URLSearchParams(); params.set("projectId", projectId); if (limit !== undefined) params.set("limit", String(limit)); if (offset !== undefined) params.set("offset", String(offset)); return api(`/tasks?${params.toString()}`); } /** Fetch project-specific config */ export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> { return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`); } /** Detected project information */ export interface DetectedProject { path: string; suggestedName: string; existing: boolean; } /** Detect projects in a base path */ export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> { return api<{ projects: DetectedProject[] }>("/projects/detect", { method: "POST", body: JSON.stringify({ basePath }), }); } /** Fetch a single project by ID */ export function fetchProject(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}`); } /** Update an existing project */ export function updateProject(id: string, updates: Partial): Promise { return api(`/projects/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(updates), }); } // ── Task Diff API ────────────────────────────────────────────────────────── /** Task diff information */ export interface TaskDiff { files: Array<{ path: string; status: "added" | "modified" | "deleted"; additions: number; deletions: number; patch: string; }>; stats: { filesChanged: number; additions: number; deletions: number; }; } /** Fetch diff for a task's changes */ export function fetchTaskDiff(taskId: string, worktree?: string, projectId?: string): Promise { const params = new URLSearchParams(); if (worktree) params.set("worktree", worktree); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/tasks/${encodeURIComponent(taskId)}/diff${query}`); } /** Individual file diff */ export interface TaskFileDiff { path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string; } /** Fetch file diffs for a task */ export function fetchTaskFileDiffs(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/file-diffs`, projectId)); } // ── Mission API ─────────────────────────────────────────────────────────── /** Mission status values */ export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived"; /** Milestone status values */ export type MilestoneStatus = "planning" | "active" | "blocked" | "complete"; /** Slice status values */ export type SliceStatus = "pending" | "active" | "complete"; /** Feature status values */ export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done"; /** Autopilot state values for mission autonomous progression */ export type AutopilotState = "inactive" | "watching" | "activating" | "completing"; /** Autopilot status for a mission */ export interface AutopilotStatus { enabled: boolean; state: AutopilotState; watched: boolean; lastActivityAt?: string; nextScheduledCheck?: string; } /** Mission entity */ export interface Mission { id: string; title: string; description?: string; status: MissionStatus; interviewState: "not_started" | "in_progress" | "completed" | "needs_update"; autoAdvance?: boolean; /** When true, enable autopilot monitoring system for this mission */ autopilotEnabled?: boolean; /** Current autopilot runtime state */ autopilotState?: AutopilotState; /** ISO-8601 timestamp of last autopilot activity */ lastAutopilotActivityAt?: string; createdAt: string; updatedAt: string; } /** Status summary for a mission card, computed from hierarchy */ export interface MissionSummary { totalMilestones: number; completedMilestones: number; totalFeatures: number; completedFeatures: number; progressPercent: number; } /** Mission with optional status summary (returned by list endpoint) */ export type MissionWithSummary = Mission & { summary?: MissionSummary }; /** Milestone entity */ export interface Milestone { id: string; missionId: string; title: string; description?: string; status: MilestoneStatus; orderIndex: number; interviewState: "not_started" | "in_progress" | "completed" | "needs_update"; dependencies: string[]; createdAt: string; updatedAt: string; } /** Slice entity */ export interface Slice { id: string; milestoneId: string; title: string; description?: string; status: SliceStatus; orderIndex: number; activatedAt?: string; createdAt: string; updatedAt: string; } /** Feature entity */ export interface MissionFeature { id: string; sliceId: string; taskId?: string; title: string; description?: string; acceptanceCriteria?: string; status: FeatureStatus; createdAt: string; updatedAt: string; } /** Milestone with slices (each slice has features) */ export interface MilestoneWithSlices extends Milestone { slices: SliceWithFeatures[]; } /** Slice with features */ export interface SliceWithFeatures extends Slice { features: MissionFeature[]; } /** Full mission hierarchy */ export interface MissionWithHierarchy extends Mission { milestones: MilestoneWithSlices[]; } /** Fetch all missions with status summary */ export function fetchMissions(projectId?: string): Promise { return api(withProjectId("/missions", projectId)); } /** Create a new mission */ export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean }, projectId?: string): Promise { return api(withProjectId("/missions", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Get mission with full hierarchy */ export function fetchMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId)); } /** Update mission */ export function updateMission(missionId: string, updates: Partial, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete mission */ export function deleteMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), { method: "DELETE", }); } /** Get mission computed status */ export function fetchMissionStatus(missionId: string, projectId?: string): Promise<{ status: string }> { return api<{ status: string }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/status`, projectId)); } /** Query options for paginated mission event logs. */ export interface MissionEventQueryOptions { limit?: number; offset?: number; eventType?: MissionEventType; } /** Paginated mission event log response. */ export interface MissionEventsResponse { events: MissionEvent[]; total: number; limit: number; offset: number; } /** Fetch paginated mission observability events. */ export function fetchMissionEvents( missionId: string, options?: MissionEventQueryOptions, projectId?: string, ): Promise { const query = new URLSearchParams(); if (options?.limit !== undefined) query.set("limit", String(options.limit)); if (options?.offset !== undefined) query.set("offset", String(options.offset)); if (options?.eventType !== undefined) query.set("eventType", options.eventType); const suffix = query.size > 0 ? `?${query.toString()}` : ""; return api( withProjectId(`/missions/${encodeURIComponent(missionId)}/events${suffix}`, projectId), ); } /** Fetch computed mission health metrics. */ export function fetchMissionHealth(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId)); } /** Fetch health metrics for all missions in a single batched request. */ export function fetchMissionsHealth(projectId?: string): Promise> { return api>(withProjectId("/missions/health", projectId)); } /** Add milestone to mission */ export function createMilestone( missionId: string, input: { title: string; description?: string; dependencies?: string[] }, projectId?: string ): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update milestone */ export function updateMilestone(milestoneId: string, updates: Partial, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete milestone */ export function deleteMilestone(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), { method: "DELETE", }); } /** Reorder milestones */ export function reorderMilestones(missionId: string, orderedIds: string[], projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, projectId), { method: "POST", body: JSON.stringify({ orderedIds }), }); } /** Add slice to milestone */ export function createSlice( milestoneId: string, input: { title: string; description?: string }, projectId?: string ): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update slice */ export function updateSlice(sliceId: string, updates: Partial, projectId?: string): Promise { return api(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete slice */ export function deleteSlice(sliceId: string, projectId?: string): Promise { return api(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), { method: "DELETE", }); } /** Activate slice */ export function activateSlice(sliceId: string, projectId?: string): Promise { return api(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, projectId), { method: "POST", }); } /** Reorder slices */ export function reorderSlices(milestoneId: string, orderedIds: string[], projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, projectId), { method: "POST", body: JSON.stringify({ orderedIds }), }); } /** Add feature to slice */ export function createFeature( sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }, projectId?: string ): Promise { return api(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/features`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update feature */ export function updateFeature(featureId: string, updates: Partial, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete feature */ export function deleteFeature(featureId: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), { method: "DELETE", }); } /** Link feature to task */ export function linkFeatureToTask(featureId: string, taskId: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/link-task`, projectId), { method: "POST", body: JSON.stringify({ taskId }), }); } /** Unlink feature from task */ export function unlinkFeatureFromTask(featureId: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, projectId), { method: "POST", }); } /** Triage a feature — create a task from the feature and link it */ export function triageFeature(featureId: string, taskTitle?: string, taskDescription?: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), { method: "POST", body: JSON.stringify({ taskTitle, taskDescription }), }); } /** Triage all "defined" features in a slice */ export function triageAllSliceFeatures(sliceId: string, projectId?: string): Promise<{ triaged: MissionFeature[]; count: number }> { return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), { method: "POST", }); } // ── Contract Assertion API ───────────────────────────────────────────────────── /** Contract assertion status */ export type MissionAssertionStatus = "pending" | "passed" | "failed" | "blocked"; /** A contract assertion represents an explicit behavioral test or requirement associated with a milestone */ export interface MissionContractAssertion { id: string; milestoneId: string; title: string; assertion: string; status: MissionAssertionStatus; orderIndex: number; createdAt: string; updatedAt: string; } /** Input for creating a contract assertion */ export interface ContractAssertionCreateInput { title: string; assertion: string; status?: MissionAssertionStatus; } /** Input for updating a contract assertion */ export interface ContractAssertionUpdateInput { title?: string; assertion?: string; status?: MissionAssertionStatus; } /** List assertions for a milestone, ordered by orderIndex */ export function fetchAssertions(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId)); } /** Create a new assertion for a milestone */ export function createAssertion(milestoneId: string, input: ContractAssertionCreateInput, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Reorder assertions within a milestone */ export function reorderAssertions(milestoneId: string, orderedIds: string[], projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions/reorder`, projectId), { method: "POST", body: JSON.stringify({ orderedIds }), }); } /** Get a single assertion by ID */ export function fetchAssertion(assertionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId)); } /** Update an assertion */ export function updateAssertion(assertionId: string, updates: ContractAssertionUpdateInput, projectId?: string): Promise { return api(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete an assertion */ export function deleteAssertion(assertionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), { method: "DELETE", }); } /** Link a feature to an assertion */ export function linkFeatureToAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/link`, projectId), { method: "POST", }); } /** Unlink a feature from an assertion */ export function unlinkFeatureFromAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/unlink`, projectId), { method: "POST", }); } /** List assertions linked to a feature */ export function fetchAssertionsForFeature(featureId: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions`, projectId)); } /** List features linked to an assertion */ export function fetchFeaturesForAssertion(assertionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}/features`, projectId)); } /** Validation rollup for a milestone */ export interface MilestoneValidationRollup { milestoneId: string; totalAssertions: number; passedAssertions: number; failedAssertions: number; blockedAssertions: number; pendingAssertions: number; unlinkedAssertions: number; state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked"; } /** Get milestone validation rollup */ export function fetchMilestoneValidation(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/validation`, projectId)); } /** Fetch grouped validation telemetry for a milestone */ export function fetchMilestoneValidationTelemetry(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/validation-telemetry`, projectId)); } // ── Validation Loop API ─────────────────────────────────────────────────────── /** Loop state snapshot for a feature */ export interface MissionFeatureLoopSnapshot { featureId: string; feature: MissionFeature; loopState: "idle" | "implementing" | "validating" | "needs_fix" | "passed" | "blocked"; implementationAttemptCount: number; validatorAttemptCount: number; lastValidatorRunId?: string; lastValidatorStatus?: "running" | "passed" | "failed" | "blocked" | "error"; generatedFromFeatureId?: string; generatedFromRunId?: string; retryBudgetRemaining: number; } /** Validator run */ export interface MissionValidatorRun { id: string; featureId: string; milestoneId: string; sliceId: string; status: "running" | "passed" | "failed" | "blocked" | "error"; triggerType: string; implementationAttempt: number; validatorAttempt: number; summary?: string; blockedReason?: string; startedAt: string; completedAt?: string; createdAt: string; updatedAt: string; } /** Trigger validation for a feature */ export function triggerValidation(featureId: string, projectId?: string): Promise<{ runId: string; featureId: string; status: string; triggerType: string; implementationAttempt: number; validatorAttempt: number; startedAt: string }> { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validate`, projectId), { method: "POST", }); } /** Get validation loop state for a feature */ export function fetchValidationLoopState(featureId: string, projectId?: string): Promise { return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-loop`, projectId)); } /** Paginated response wrapper for validation runs */ export interface ValidationRunsResponse { runs: MissionValidatorRun[]; total: number; limit: number; offset: number; } /** List validation runs for a feature */ export function fetchValidationRuns(featureId: string, options?: { limit?: number; offset?: number }, projectId?: string): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); const suffix = params.size > 0 ? `?${params.toString()}` : ""; return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-runs${suffix}`, projectId)) .then((response) => response.runs); } /** Get a single validator run */ export function fetchValidationRun(runId: string, projectId?: string): Promise }> { return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId)); } /** Pause a mission (sets status to "blocked", in-flight tasks continue) */ export function pauseMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), { method: "POST", }); } /** Resume a paused mission (sets status back to "active") */ export function resumeMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/resume`, projectId), { method: "POST", }); } /** Stop a mission (sets status to "blocked" and pauses all linked tasks) */ export function stopMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/stop`, projectId), { method: "POST", }); } /** Start a planning mission: sets status to "active" and activates the first pending slice */ export function startMission(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/start`, projectId), { method: "POST", }); } // ── Mission Autopilot API ──────────────────────────────────────────────── /** Fetch autopilot status for a mission */ export function fetchMissionAutopilotStatus(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId)); } /** Update autopilot settings for a mission (enable/disable) */ export function updateMissionAutopilot(missionId: string, updates: { enabled?: boolean }, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Manually start autopilot watching for a mission */ export function startMissionAutopilot(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/start`, projectId), { method: "POST", }); } /** Manually stop autopilot watching for a mission */ export function stopMissionAutopilot(missionId: string, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/stop`, projectId), { method: "POST", }); } // ── Mission Interview API ───────────────────────────────────────────────── /** Mission plan types returned by the interview AI */ export interface MissionPlanFeature { title: string; description?: string; acceptanceCriteria?: string; } export interface MissionPlanSlice { title: string; description?: string; verification?: string; features: MissionPlanFeature[]; } export interface MissionPlanMilestone { title: string; description?: string; verification?: string; slices: MissionPlanSlice[]; } export interface MissionPlanSummary { missionTitle?: string; missionDescription?: string; milestones: MissionPlanMilestone[]; } export type MissionInterviewResponse = | { type: "question"; data: PlanningQuestion } | { type: "complete"; data: MissionPlanSummary }; /** Start a mission interview session with AI streaming */ export function startMissionInterview( missionTitle: string, projectId?: string, modelOverride?: { modelProvider?: string; modelId?: string }, ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/missions/interview/start", projectId), { method: "POST", body: JSON.stringify({ missionTitle, modelProvider: modelOverride?.modelProvider, modelId: modelOverride?.modelId, }), }); } /** Submit a response to the current interview question */ export function respondToMissionInterview( sessionId: string, responses: Record, projectId?: string, tabId?: string, ): Promise { return api(withProjectId("/missions/interview/respond", projectId), { method: "POST", body: JSON.stringify({ sessionId, responses, tabId }), }); } /** Retry a failed mission interview turn */ export function retryMissionInterviewSession( sessionId: string, projectId?: string, tabId?: string, ): Promise<{ success: boolean; sessionId: string }> { return api<{ success: boolean; sessionId: string }>( withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId), { method: "POST", ...(tabId ? { body: JSON.stringify({ tabId }) } : {}), }, ); } /** Cancel an active mission interview session */ export function cancelMissionInterview(sessionId: string, projectId?: string, tabId?: string): Promise { return api(withProjectId("/missions/interview/cancel", projectId), { method: "POST", body: JSON.stringify({ sessionId, tabId }), }); } /** Create mission from completed interview */ export function createMissionFromInterview( sessionId: string, summary?: MissionPlanSummary, projectId?: string ): Promise { return api(withProjectId("/missions/interview/create-mission", projectId), { method: "POST", body: JSON.stringify({ sessionId, summary }), }); } /** Connect to mission interview SSE stream and handle events */ export function connectMissionInterviewStream( sessionId: string, projectId: string | undefined, handlers: { onThinking?: (data: string) => void; onQuestion?: (data: PlanningQuestion) => void; onSummary?: (data: MissionPlanSummary) => void; onError?: (data: string) => void; onComplete?: () => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId)); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; const resilient = createResilientEventSource( url, { onOpen: () => { stopKeepAlive(); keepAlive = startKeepAlive(sessionId, projectId); }, onMessage: (event) => { if (event.data.startsWith(":")) return; }, events: { thinking: (event) => { try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); } }, question: (event) => { try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch (err) { console.error("[mission-interview] Failed to parse question event:", err); } }, summary: (event) => { try { handlers.onSummary?.(JSON.parse(event.data) as MissionPlanSummary); } catch (err) { console.error("[mission-interview] Failed to parse summary event:", err); } }, error: (event) => { try { const parsed = JSON.parse(event.data); handlers.onError?.(parsed.message || parsed); } catch { handlers.onError?.(event.data || "Stream error"); } connection?.close(); }, complete: () => { handlers.onComplete?.(); connection?.close(); }, }, }, { maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { stopKeepAlive(); handlers.onError?.(message); }, }, ); connection = { close: () => { stopKeepAlive(); resilient.close(); }, isConnected: resilient.isConnected, }; return connection; } // ── Milestone/Slice Interview API ───────────────────────────────────────── /** Summary type for milestone/slice interview responses */ export interface TargetInterviewSummary { title?: string; description?: string; planningNotes?: string; verification?: string; } /** Response from milestone/slice interview: either a question or a completed plan */ export type TargetInterviewResponse = | { type: "question"; data: PlanningQuestion } | { type: "complete"; data: TargetInterviewSummary }; // Helper functions for URL construction function buildMilestoneInterviewUrl(milestoneId: string, path: string, projectId?: string): string { return withProjectId( `/missions/milestones/${encodeURIComponent(milestoneId)}/interview${path}`, projectId ); } function buildSliceInterviewUrl(sliceId: string, path: string, projectId?: string): string { return withProjectId( `/missions/slices/${encodeURIComponent(sliceId)}/interview${path}`, projectId ); } /** Start a milestone interview session */ export function startMilestoneInterview( milestoneId: string, projectId?: string, ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(buildMilestoneInterviewUrl(milestoneId, "/start", projectId), { method: "POST", }); } /** Submit a response to a milestone interview question */ export function respondToMilestoneInterview( sessionId: string, responses: Record, projectId?: string, tabId?: string, ): Promise { return api(buildMilestoneInterviewUrl(sessionId, "/respond", projectId), { method: "POST", body: JSON.stringify({ sessionId, responses, tabId }), }); } /** Connect to milestone interview SSE stream and handle events */ export function connectMilestoneInterviewStream( sessionId: string, projectId: string | undefined, handlers: { onThinking?: (data: string) => void; onQuestion?: (data: PlanningQuestion) => void; onSummary?: (data: TargetInterviewSummary) => void; onError?: (data: string) => void; onComplete?: () => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { const url = buildApiUrl(buildMilestoneInterviewUrl(sessionId, `/${encodeURIComponent(sessionId)}/stream`, projectId)); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; const resilient = createResilientEventSource( url, { onOpen: () => { stopKeepAlive(); keepAlive = startKeepAlive(sessionId, projectId); }, onMessage: (event) => { if (event.data.startsWith(":")) return; }, events: { thinking: (event) => { try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); } }, question: (event) => { try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch (err) { console.error("[milestone-interview] Failed to parse question event:", err); } }, summary: (event) => { try { handlers.onSummary?.(JSON.parse(event.data) as TargetInterviewSummary); } catch (err) { console.error("[milestone-interview] Failed to parse summary event:", err); } }, error: (event) => { try { const parsed = JSON.parse(event.data); handlers.onError?.(parsed.message || parsed); } catch { handlers.onError?.(event.data || "Stream error"); } connection?.close(); }, complete: () => { handlers.onComplete?.(); connection?.close(); }, }, }, { maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { stopKeepAlive(); handlers.onError?.(message); }, }, ); connection = { close: () => { stopKeepAlive(); resilient.close(); }, isConnected: resilient.isConnected, }; return connection; } /** Apply milestone interview results to the milestone */ export function applyMilestoneInterview( sessionId: string, summary?: TargetInterviewSummary, projectId?: string, ): Promise { return api(buildMilestoneInterviewUrl(sessionId, "/apply", projectId), { method: "POST", body: JSON.stringify({ sessionId, summary }), }); } /** Skip milestone interview and use mission context */ export function skipMilestoneInterview( milestoneId: string, projectId?: string, ): Promise { return api(buildMilestoneInterviewUrl(milestoneId, "/skip", projectId), { method: "POST", }); } /** Start a slice interview session */ export function startSliceInterview( sliceId: string, projectId?: string, ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(buildSliceInterviewUrl(sliceId, "/start", projectId), { method: "POST", }); } /** Submit a response to a slice interview question */ export function respondToSliceInterview( sessionId: string, responses: Record, projectId?: string, tabId?: string, ): Promise { return api(buildSliceInterviewUrl(sessionId, "/respond", projectId), { method: "POST", body: JSON.stringify({ sessionId, responses, tabId }), }); } /** Connect to slice interview SSE stream and handle events */ export function connectSliceInterviewStream( sessionId: string, projectId: string | undefined, handlers: { onThinking?: (data: string) => void; onQuestion?: (data: PlanningQuestion) => void; onSummary?: (data: TargetInterviewSummary) => void; onError?: (data: string) => void; onComplete?: () => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { const url = buildApiUrl(buildSliceInterviewUrl(sessionId, `/${encodeURIComponent(sessionId)}/stream`, projectId)); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; const resilient = createResilientEventSource( url, { onOpen: () => { stopKeepAlive(); keepAlive = startKeepAlive(sessionId, projectId); }, onMessage: (event) => { if (event.data.startsWith(":")) return; }, events: { thinking: (event) => { try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); } }, question: (event) => { try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch (err) { console.error("[slice-interview] Failed to parse question event:", err); } }, summary: (event) => { try { handlers.onSummary?.(JSON.parse(event.data) as TargetInterviewSummary); } catch (err) { console.error("[slice-interview] Failed to parse summary event:", err); } }, error: (event) => { try { const parsed = JSON.parse(event.data); handlers.onError?.(parsed.message || parsed); } catch { handlers.onError?.(event.data || "Stream error"); } connection?.close(); }, complete: () => { handlers.onComplete?.(); connection?.close(); }, }, }, { maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { stopKeepAlive(); handlers.onError?.(message); }, }, ); connection = { close: () => { stopKeepAlive(); resilient.close(); }, isConnected: resilient.isConnected, }; return connection; } /** Apply slice interview results to the slice */ export function applySliceInterview( sessionId: string, summary?: TargetInterviewSummary, projectId?: string, ): Promise { return api(buildSliceInterviewUrl(sessionId, "/apply", projectId), { method: "POST", body: JSON.stringify({ sessionId, summary }), }); } /** Skip slice interview and use mission context */ export function skipSliceInterview( sliceId: string, projectId?: string, ): Promise { return api(buildSliceInterviewUrl(sliceId, "/skip", projectId), { method: "POST", }); } /** Preview enriched description for a feature before triage */ export async function previewEnrichedDescription( featureId: string, projectId?: string, ): Promise<{ description: string }> { try { return await api<{ description: string }>( withProjectId(`/missions/features/${encodeURIComponent(featureId)}/preview-description`, projectId), { method: "POST", } ); } catch { // If endpoint doesn't exist, throw to trigger fallback throw new Error("Preview endpoint not available"); } } // ── Roadmap API ───────────────────────────────────────────────────────────────── /** Fetch all roadmaps */ export function fetchRoadmaps(projectId?: string): Promise { return api(withProjectId("/roadmaps", projectId)); } /** Create a new roadmap */ export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise { return api(withProjectId("/roadmaps", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Fetch a single roadmap with full hierarchy (milestones and features) */ export function fetchRoadmap(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId)); } /** Update roadmap metadata */ export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete a roadmap */ export function deleteRoadmap(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), { method: "DELETE", }); } /** Fetch milestones for a roadmap */ export function fetchRoadmapMilestones(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId)); } /** Create a milestone in a roadmap */ export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update milestone metadata */ export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise { return api(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete a milestone */ export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), { method: "DELETE", }); } /** Fetch features for a milestone */ export function fetchRoadmapFeatures(milestoneId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId)); } /** Create a feature in a milestone */ export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise { return api(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Update feature metadata */ export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise { return api(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete a feature */ export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), { method: "DELETE", }); } /** Reorder milestones within a roadmap */ export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), { method: "POST", body: JSON.stringify({ orderedMilestoneIds }), }); } /** Reorder features within a milestone */ export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise { return api(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), { method: "POST", body: JSON.stringify({ orderedFeatureIds }), }); } /** Move a feature to a different milestone or position */ export function moveRoadmapFeature( featureId: string, targetMilestoneId: string, targetIndex: number, projectId?: string ): Promise { return api(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), { method: "POST", body: JSON.stringify({ targetMilestoneId, targetIndex }), }); } /** Export a roadmap as a flat bundle for persistence/import/export */ export function exportRoadmap(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId)); } /** Get mission planning handoff payload for a roadmap */ export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId)); } /** Get task planning handoff payload for a single roadmap feature */ export function getRoadmapFeatureHandoff( roadmapId: string, milestoneId: string, featureId: string, projectId?: string ): Promise { return api( withProjectId( `/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`, projectId ) ); } /** Combined handoff response type for roadmap handoff endpoint */ export interface RoadmapHandoffResponse { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[]; } /** Get both mission and feature handoff payloads for a roadmap */ export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise { return api(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId)); } /** Response from milestone suggestion generation */ export interface MilestoneSuggestionsResponse { suggestions: Array<{ title: string; description?: string; }>; } /** Generate milestone suggestions from a goal prompt */ export function generateMilestoneSuggestions( roadmapId: string, goalPrompt: string, count?: number, projectId?: string ): Promise { return api( withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId), { method: "POST", body: JSON.stringify({ goalPrompt: goalPrompt.trim(), ...(count !== undefined ? { count } : {}), }), } ); } /** Response type for feature suggestions */ export interface FeatureSuggestionsResponse { suggestions: Array<{ title: string; description?: string; }>; } /** Input for generating feature suggestions */ export interface GenerateFeatureSuggestionsInput { /** Optional prompt to guide feature generation */ prompt?: string; /** Number of features to generate (default 5, max 10) */ count?: number; } /** Generate feature suggestions for a milestone */ export function generateFeatureSuggestions( milestoneId: string, input?: GenerateFeatureSuggestionsInput, projectId?: string ): Promise { return api( withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId), { method: "POST", body: JSON.stringify({ ...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}), ...(input?.count !== undefined ? { count: input.count } : {}), }), } ); } // ── AI Sessions (Background Tasks) ───────────────────────────────────────── export interface AiSessionSummary { id: string; type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview"; status: "generating" | "awaiting_input" | "complete" | "error"; title: string; projectId: string | null; lockedByTab: string | null; updatedAt: string; } export interface ConversationHistoryEntry { question?: PlanningQuestion; response?: Record; thinkingOutput?: string; } export interface AiSessionDetail extends AiSessionSummary { inputPayload: string; conversationHistory: string; currentQuestion: string | null; result: string | null; thinkingOutput: string; error: string | null; createdAt: string; lockedAt: string | null; } export function parseConversationHistory(raw: string): ConversationHistoryEntry[] { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } export async function fetchAiSessions(projectId?: string): Promise { const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const res = await fetch(buildApiUrl(`/ai-sessions${params}`), { headers: withTokenHeader(), }); if (!res.ok) return []; const data = await res.json(); return data.sessions ?? []; } export async function fetchAiSession(id: string): Promise { const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { headers: withTokenHeader(), }); if (!res.ok) return null; return res.json(); } export async function acquireSessionLock( sessionId: string, tabId: string, ): Promise<{ acquired: boolean; currentHolder: string | null }> { const result = await api<{ acquired: boolean; currentHolder?: string | null }>( `/ai-sessions/${encodeURIComponent(sessionId)}/lock`, { method: "POST", body: JSON.stringify({ tabId }), }, ); return { acquired: result.acquired, currentHolder: result.currentHolder ?? null, }; } export function releaseSessionLock(sessionId: string, tabId: string): Promise { return api(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, { method: "DELETE", body: JSON.stringify({ tabId }), }); } export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise { return api(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, { method: "POST", body: JSON.stringify({ tabId }), }); } export async function deleteAiSession(id: string): Promise { await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { method: "DELETE", headers: withTokenHeader(), }); } export function pingSession(sessionId: string, projectId?: string): Promise<{ ok: boolean }> { return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/ping`, projectId), { method: "POST", }); } // ── Messages API ────────────────────────────────────────────────────────── /** Response shape for GET /messages/inbox */ export interface InboxResponse { messages: Message[]; total: number; unreadCount: number; } /** Response shape for GET /messages/outbox */ export interface OutboxResponse { messages: Message[]; total: number; } /** Response shape for GET /messages/unread-count */ export interface UnreadCountResponse { unreadCount: number; } /** Response shape for POST /messages/read-all */ export interface MarkAllReadResponse { markedAsRead: number; } /** Response shape for GET /agents/:id/mailbox */ export interface AgentMailboxResponse { ownerId: string; ownerType: ParticipantType; unreadCount: number; lastMessage?: Message; messages: Message[]; // Backward compat alias for inbox inbox: Message[]; outbox: Message[]; } /** Input for sending a message via the dashboard */ export interface SendMessageInput { toId: string; toType: ParticipantType; content: string; type: MessageType; metadata?: MessageMetadata; } /** Fetch inbox messages for the current user. */ export function fetchInbox( options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); if (options?.unreadOnly) params.set("unreadOnly", "true"); if (options?.type) params.set("type", options.type); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/messages/inbox${query}`); } /** Fetch sent messages for the current user. */ export function fetchOutbox( options?: { limit?: number; offset?: number; type?: MessageType }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); if (options?.type) params.set("type", options.type); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/messages/outbox${query}`); } /** Fetch unread message count (lightweight, for header badge). */ export function fetchUnreadCount(projectId?: string): Promise { return api(withProjectId("/messages/unread-count", projectId)); } /** Fetch a single message by ID. */ export function fetchMessage(id: string, projectId?: string): Promise { return api(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId)); } /** Send a new message. */ export function sendMessage(input: SendMessageInput, projectId?: string): Promise { return api(withProjectId("/messages", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Mark a specific message as read. */ export function markMessageRead(id: string, projectId?: string): Promise { return api(withProjectId(`/messages/${encodeURIComponent(id)}/read`, projectId), { method: "POST", }); } /** Mark all inbox messages as read. */ export function markAllMessagesRead(projectId?: string): Promise { return api(withProjectId("/messages/read-all", projectId), { method: "POST", }); } /** Delete a message. */ export function deleteMessage(id: string, projectId?: string): Promise { return api(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), { method: "DELETE", }); } /** Fetch conversation between current user and a specific participant. */ export function fetchConversation( participantId: string, participantType: ParticipantType, projectId?: string, ): Promise { const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`; return api(withProjectId(path, projectId)); } /** Fetch an agent's mailbox (admin read-only view). */ export function fetchAgentMailbox(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId)); } /** Fetch reflection history for an agent. */ export function fetchAgentReflections(agentId: string, limit?: number, projectId?: string): Promise { const params = new URLSearchParams(); if (limit !== undefined) params.set("limit", String(limit)); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents/${encodeURIComponent(agentId)}/reflections${query}`); } /** Fetch the most recent reflection for an agent. */ export function fetchAgentReflection(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/reflections/latest`, projectId)); } /** Trigger a manual reflection for an agent. */ export function triggerAgentReflection(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/reflections`, projectId), { method: "POST", }); } /** Fetch aggregated performance summary for an agent. */ export function fetchAgentPerformance(agentId: string, windowMs?: number, projectId?: string): Promise { const params = new URLSearchParams(); if (windowMs !== undefined) params.set("windowMs", String(windowMs)); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents/${encodeURIComponent(agentId)}/performance${query}`); } /** Fetch ratings for an agent */ export function fetchAgentRatings( agentId: string, options?: { limit?: number; category?: string }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.category) params.set("category", options.category); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api(`/agents/${encodeURIComponent(agentId)}/ratings${query}`); } /** Add a rating for an agent */ export function addAgentRating( agentId: string, input: AgentRatingInput, projectId?: string, ): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings`, projectId), { method: "POST", body: JSON.stringify(input), }); } /** Fetch rating summary for an agent */ export function fetchAgentRatingSummary(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/summary`, projectId)); } /** Delete a specific rating */ export function deleteAgentRating(agentId: string, ratingId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/${encodeURIComponent(ratingId)}`, projectId), { method: "DELETE", }); } // ── Agent Budget API ────────────────────────────────────────────────────── /** Fetch budget status for an agent */ export function fetchAgentBudgetStatus(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget`, projectId)); } /** Reset budget usage for an agent */ export function resetAgentBudget(agentId: string, projectId?: string): Promise { return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget/reset`, projectId), { method: "POST", }); } // ── Plugin Management ──────────────────────────────────────────────────────── /** Fetch all installed plugins */ export async function fetchPlugins(projectId?: string): Promise { return api(withProjectId("/plugins", projectId)); } /** Fetch a single plugin by ID */ export async function fetchPluginDetail(id: string, projectId?: string): Promise { return api(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId)); } /** Install a plugin from local path or npm package */ export async function installPlugin( source: { path: string } | { package: string }, projectId?: string, ): Promise { return api(withProjectId("/plugins", projectId), { method: "POST", body: JSON.stringify({ mode: "install", ...source }), }); } /** Enable a plugin */ export async function enablePlugin(id: string, projectId?: string): Promise { return api(withProjectId(`/plugins/${encodeURIComponent(id)}/enable`, projectId), { method: "POST", }); } /** Disable a plugin */ export async function disablePlugin(id: string, projectId?: string): Promise { return api(withProjectId(`/plugins/${encodeURIComponent(id)}/disable`, projectId), { method: "POST", }); } /** Uninstall a plugin */ export async function uninstallPlugin(id: string, projectId?: string): Promise { return api(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId), { method: "DELETE", }); } /** Fetch plugin settings */ export async function fetchPluginSettings(id: string, projectId?: string): Promise> { return api>(withProjectId(`/plugins/${encodeURIComponent(id)}/settings`, projectId)); } /** Update plugin settings */ export async function updatePluginSettings( id: string, settings: Record, projectId?: string, ): Promise> { return api>(withProjectId(`/plugins/${encodeURIComponent(id)}/settings`, projectId), { method: "PUT", body: JSON.stringify({ settings }), }); } /** Reload a running plugin with updated code */ export async function reloadPlugin(id: string, projectId?: string): Promise { return api(withProjectId(`/plugins/${encodeURIComponent(id)}/reload`, projectId), { method: "POST", }); } /** A UI slot entry returned by GET /api/plugins/ui-slots */ export interface PluginUiSlotEntry { pluginId: string; slot: PluginUiSlotDefinition; } /** Fetch all UI slot definitions from active plugins */ export async function fetchPluginUiSlots(projectId?: string): Promise { return api(withProjectId("/plugins/ui-slots", projectId)); } // ── Skills Management ───────────────────────────────────────────────────────── /** Fetch all discovered skills with their enabled state */ export async function fetchDiscoveredSkills(projectId?: string): Promise { const response = await api<{ skills: DiscoveredSkill[] }>(withProjectId("/skills/discovered", projectId)); return response.skills; } /** Toggle a skill's enabled/disabled state */ export async function toggleExecutionSkill( skillId: string, enabled: boolean, projectId?: string, ): Promise { return api(withProjectId("/skills/execution", projectId), { method: "PATCH", body: JSON.stringify({ skillId, enabled }), }); } /** Fetch the skills.sh catalog */ export async function fetchSkillsCatalog( query?: string, limit?: number, projectId?: string, ): Promise { const params = new URLSearchParams(); if (query) params.set("q", query); if (limit !== undefined) params.set("limit", String(limit)); const suffix = params.size > 0 ? `?${params.toString()}` : ""; return api(withProjectId(`/skills/catalog${suffix}`, projectId)); } /** Fetch the contents of a skill's SKILL.md file */ export async function fetchSkillContent(skillId: string, projectId?: string): Promise { const response = await api<{ content: SkillContent }>( withProjectId(`/skills/${encodeURIComponent(skillId)}/content`, projectId) ); return response.content; } // ── Chat API ───────────────────────────────────────────────────────────────── // EnrichedChatSession is imported from @fusion/core above export interface ChatSessionListResponse { sessions: EnrichedChatSession[]; } export interface ChatSessionResponse { session: ChatSession; } export interface ChatMessageListResponse { messages: ChatMessage[]; } /** Fetch all chat sessions for a project */ export function fetchChatSessions(projectId?: string, status?: string): Promise { const search = new URLSearchParams(); if (projectId) search.set("projectId", projectId); if (status) search.set("status", status); const qs = search.toString(); return api(`/chat/sessions${qs ? `?${qs}` : ""}`); } export interface ChatSessionResumeLookupInput { agentId: string; modelProvider?: string; modelId?: string; } /** * Fetch the most relevant active session for quick-chat resume semantics. * Returns at most one session for the provided target. */ export async function fetchResumeChatSession( input: ChatSessionResumeLookupInput, projectId?: string, ): Promise<{ session: EnrichedChatSession | null }> { const normalizedAgentId = input.agentId.trim(); if (!normalizedAgentId) { throw new Error("agentId is required"); } const normalizedProvider = input.modelProvider?.trim(); const normalizedModelId = input.modelId?.trim(); if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided"); } const search = new URLSearchParams(); search.set("lookup", "resume"); search.set("agentId", normalizedAgentId); if (projectId) search.set("projectId", projectId); if (normalizedProvider && normalizedModelId) { search.set("modelProvider", normalizedProvider); search.set("modelId", normalizedModelId); } const data = await api(`/chat/sessions?${search.toString()}`); return { session: data.sessions[0] ?? null }; } /** Create a new chat session */ export function createChatSession( input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }, projectId?: string, ): Promise { return api(withProjectId("/chat/sessions", projectId), { method: "POST", body: JSON.stringify(input), }); } /** Fetch a single chat session */ export function fetchChatSession(id: string, projectId?: string): Promise { return api(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId)); } /** Update a chat session (title, status) */ export function updateChatSession( id: string, updates: { title?: string; status?: string }, projectId?: string, ): Promise { return api(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** Delete a chat session */ export function deleteChatSession(id: string, projectId?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), { method: "DELETE", }); } /** Fetch messages for a chat session */ export function fetchChatMessages( sessionId: string, opts?: { limit?: number; offset?: number; before?: string }, projectId?: string, ): Promise { const search = new URLSearchParams(); if (opts?.limit !== undefined) search.set("limit", String(opts.limit)); if (opts?.offset !== undefined) search.set("offset", String(opts.offset)); if (opts?.before) search.set("before", opts.before); const qs = search.toString(); return api( withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`, projectId), ); } /** Delete a specific message from a chat session */ export function deleteChatMessage( sessionId: string, messageId: string, projectId?: string, ): Promise<{ success: boolean }> { return api<{ success: boolean }>( withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, projectId), { method: "DELETE", }, ); } /** Cancel an in-flight chat generation. */ export function cancelChatResponse( sessionId: string, projectId?: string, ): Promise<{ success: boolean }> { return api<{ success: boolean }>( withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, projectId), { method: "POST", }, ); } /** Send a chat message and receive the AI response via SSE streaming. * * The backend exposes `POST /api/chat/sessions/:id/messages` which returns an SSE * stream (not JSON). Events: `thinking`, `text`, `done`, `error`. * * Since `EventSource` only supports GET requests, this function uses `fetch()` * with a ReadableStream to parse SSE events from the POST response body. */ export function streamChatResponse( sessionId: string, content: string, handlers: { onThinking?: (data: string) => void; onText?: (data: string) => void; onToolStart?: (data: { toolName: string; args?: Record }) => void; onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void; onDone?: (data: { messageId: string }) => void; onError?: (data: string) => void; onConnectionStateChange?: (state: StreamConnectionState) => void; }, projectId?: string, options?: { maxReconnectAttempts?: number }, ): { close: () => void; isConnected: () => boolean } { const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, projectId)); void options; const abortController = new AbortController(); let closedByUser = false; const dispatchEvent = (eventName: string, rawData: string): void => { if (!eventName || !rawData) { return; } switch (eventName) { case "thinking": try { handlers.onThinking?.(JSON.parse(rawData)); } catch { handlers.onThinking?.(rawData); } break; case "text": try { handlers.onText?.(JSON.parse(rawData)); } catch { handlers.onText?.(rawData); } break; case "tool_start": try { handlers.onToolStart?.(JSON.parse(rawData)); } catch { // skip malformed event } break; case "tool_end": try { handlers.onToolEnd?.(JSON.parse(rawData)); } catch { // skip malformed event } break; case "done": try { handlers.onDone?.(JSON.parse(rawData)); } catch { handlers.onDone?.({ messageId: "" }); } break; case "error": try { const parsed = JSON.parse(rawData); handlers.onError?.(parsed.message || parsed); } catch { handlers.onError?.(rawData || "Stream error"); } break; } }; // Start streaming via POST (async () => { try { const res = await fetch(url, { method: "POST", headers: withTokenHeader({ "Content-Type": "application/json" }), body: JSON.stringify({ content }), signal: abortController.signal, }); if (!res.ok) { const errorBody = await res.text(); let errorMsg = `Request failed: ${res.status}`; try { const parsed = JSON.parse(errorBody); errorMsg = parsed.error || errorMsg; } catch { /* use default */ } handlers.onError?.(errorMsg); return; } if (!res.body) { handlers.onError?.("No response body"); return; } handlers.onConnectionStateChange?.("connected"); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let currentEvent = ""; let currentDataLines: string[] = []; // POST-based chat responses still speak SSE, so parser state must persist // across ReadableStream chunks. Networks can split `event:` and `data:` // lines arbitrarily, and resetting state per-read drops assistant output. const processLines = (chunk: string, flushPendingEvent = false): void => { buffer += chunk; const lines = buffer.split("\n"); buffer = lines.pop() || ""; // At stream end, flush any remaining buffered line so complete trailing // events are parsed even when the payload has no final newline. if (flushPendingEvent && buffer.length > 0) { lines.push(buffer); buffer = ""; } for (const rawLine of lines) { const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (line.startsWith("event:")) { currentEvent = line.slice(6).trim(); } else if (line.startsWith("data:")) { const value = line.slice(5); currentDataLines.push(value.startsWith(" ") ? value.slice(1) : value); } else if (line === "") { const currentData = currentDataLines.join("\n"); dispatchEvent(currentEvent, currentData); currentEvent = ""; currentDataLines = []; } } // Flush any pending event/data at stream end. // Only dispatch if we have both a valid event type and accumulated data. if (flushPendingEvent && currentEvent && currentDataLines.length > 0) { const trailingData = currentDataLines.join("\n"); dispatchEvent(currentEvent, trailingData); currentEvent = ""; currentDataLines = []; } }; while (true) { const { done, value } = await reader.read(); if (done) { processLines(decoder.decode(), true); break; } processLines(decoder.decode(value, { stream: true })); } } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { if (!closedByUser) { handlers.onError?.("Connection aborted"); } return; } if (closedByUser) return; handlers.onError?.(err instanceof Error ? err.message : "Connection error"); } })(); return { close: () => { closedByUser = true; abortController.abort(); }, isConnected: () => !closedByUser, }; } // ── Insights API ───────────────────────────────────────────────────────────── export interface InsightsListResponse { insights: Insight[]; count: number; } export interface RunsListResponse { runs: InsightRun[]; } /** * List insights for a project with optional filtering. */ export function fetchInsights( options: { category?: InsightCategory; status?: InsightStatus; runId?: string; limit?: number; offset?: number; } = {}, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options.category) params.set("category", options.category); if (options.status) params.set("status", options.status); if (options.runId) params.set("runId", options.runId); if (options.limit !== undefined) params.set("limit", String(options.limit)); if (options.offset !== undefined) params.set("offset", String(options.offset)); const suffix = params.size > 0 ? `?${params.toString()}` : ""; return api(withProjectId(`/insights${suffix}`, projectId)); } /** * Get a single insight by ID. */ export function fetchInsight(id: string, projectId?: string): Promise { return api(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId)); } /** * Update an insight. */ export function updateInsight( id: string, updates: { title?: string; content?: string | null; category?: InsightCategory; status?: InsightStatus; }, projectId?: string, ): Promise { return api(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), }); } /** * Delete an insight. */ export function deleteInsight(id: string, projectId?: string): Promise { return api(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId), { method: "DELETE", }); } /** * Dismiss an insight (set status to dismissed). */ export function dismissInsight(id: string, projectId?: string): Promise { return api(withProjectId(`/insights/${encodeURIComponent(id)}/dismiss`, projectId), { method: "POST", }); } /** * Trigger a manual insight generation run. */ export function triggerInsightRun( trigger: InsightRunTrigger = "manual", inputMetadata?: InsightRun["inputMetadata"], projectId?: string, ): Promise { return api(withProjectId("/insights/run", projectId), { method: "POST", body: JSON.stringify({ trigger, inputMetadata }), }); } /** * List insight generation runs. */ export function fetchInsightRuns(projectId?: string): Promise { return api(withProjectId("/insights/runs", projectId)); } /** * Get a single insight run by ID. */ export function fetchInsightRun(id: string, projectId?: string): Promise { return api(withProjectId(`/insights/runs/${encodeURIComponent(id)}`, projectId)); } /** * Get data needed to create a task from an insight. */ export function getInsightCreateTaskData( id: string, projectId?: string, ): Promise<{ success: boolean; insight: Insight; suggestedTitle: string; suggestedDescription: string; }> { return api(withProjectId(`/insights/${encodeURIComponent(id)}/create-task`, projectId), { method: "POST", }); }