const BASE = '/api'; /** * 4-state representation of a secret field on the wire. See design doc * 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior → * Secret Inputs) for the canonical contract. * * Phase 1 (this release) keeps the on-wire representation backwards * compatible with the existing `apiKey: string` shape so that mask- * preservation in `ConfigManager.updateConfig` keeps working unchanged: * * - `unchanged` → serialized as the masked sentinel `'********'` * - `literal` → serialized as the raw string value * - `env_ref` → serialized as `${ENV_NAME}` * - `cleared` → serialized as `''` (empty string) * * Phase 2 will switch to a tagged object on the wire and drop the magic * `'********'` sentinel; the UI form keeps the 4-state union today so the * Phase 2 migration is a server-side change only. */ export type SecretFieldValue = | { type: 'unchanged' } | { type: 'literal'; value: string } | { type: 'env_ref'; env_name: string } | { type: 'cleared' }; /** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */ export const SECRET_MASKED_SENTINEL = '********'; /** * Parse a stored string secret (as received from `GET /api/config`) into * the 4-state form used by the UI. The server masks literal secrets to * `'********'`, so any literal-looking string is treated as `unchanged` * unless it's an `${ENV_REF}` pattern. Empty / missing values map to * `cleared`. */ export function parseSecretValue(raw: string | null | undefined): SecretFieldValue { if (raw == null || raw === '') return { type: 'cleared' }; if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' }; const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim()); if (envMatch) return { type: 'env_ref', env_name: envMatch[1] }; // Anything else came back as plaintext (e.g. fresh UI form not yet // round-tripped through the server mask) — treat as literal. return { type: 'literal', value: raw }; } /** * Serialize a 4-state secret into the string the server currently * expects. `unchanged` becomes the masked sentinel so server-side * mask-preservation in `ConfigManager.updateConfig` keeps the existing * literal in place. */ export function serializeSecretValue(v: SecretFieldValue): string { if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL; if (v.type === 'literal') return v.value; if (v.type === 'env_ref') return `\${${v.env_name}}`; return ''; } export type PieceName = string; // Dynamically loaded from API export type ProfileName = 'auto' | 'fast' | 'quality'; export type OutputFormat = 'text' | 'markdown' | 'json'; export type AskPolicy = 'low' | 'high'; export type Priority = 'low' | 'medium' | 'high'; export type Visibility = 'private' | 'org' | 'public'; export interface UserOrg { orgId: string; orgName: string; fetchedAt: string; } export async function fetchMyOrgs(): Promise { const res = await fetch('/api/users/me/orgs'); if (!res.ok) return []; const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> }; return orgs; } export interface SubtaskInfo { id: string; issueNumber: number; status: string; instruction: string; worktreePath: string | null; createdAt: string; updatedAt: string; children?: SubtaskInfo[]; childCount?: number; childCompleted?: number; } export interface SubtaskActivity { jobId: string; issueNumber: number; status: string; currentMovement: string | null; currentActivity: string | null; activityLog: string; } export type TitleSource = 'auto' | 'agent' | 'user'; export interface LocalTask { id: number; title: string; /** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */ titleSource?: TitleSource; body: string; pieceName: string; profile: string; outputFormat: string; askPolicy: string; priority: string; state: string; workspacePath: string | null; ownerId?: string | null; ownerName?: string | null; visibility?: Visibility; visibilityScopeOrgId?: string | null; visibilityScopeOrgName?: string | null; createdAt: string; updatedAt: string; latestJob?: { id: string; status: string; waitReason?: string | null; currentMovement?: string | null; currentActivity?: string | null; workerId?: string | null; /** * Physical backend id (e.g. LiteLLM deployment) for jobs run through * a proxy worker. NULL until the proxy has resolved a backend or for * direct workers entirely. * Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md. */ lastBackendId?: string | null; contextPromptTokens?: number | null; contextLimitTokens?: number | null; contextUpdatedAt?: string | null; } | null; subtasks?: SubtaskInfo[]; subtaskCount?: number; subtaskCompleted?: number; feedbackRating?: 'good' | 'bad' | null; feedbackTags?: string[] | null; feedbackComment?: string | null; feedbackAt?: string | null; shareToken?: string | null; sharedAt?: string | null; missionBrief?: MissionBrief | null; } export interface MissionBrief { goal: string; done: string; open: string; clarifications: string; } export async function updateMissionBrief( taskId: number, patch: Partial, ): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } const data = await res.json(); return data.missionBrief ?? null; } export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection'; export interface LocalTaskComment { id: number; taskId: number; author: string; kind: CommentKind; body: string; createdAt: string; injectedAt: string | null; } export interface LocalFileEntry { name: string; path: string; kind: 'directory' | 'file'; size: number; modifiedAt: string; } export interface CreateLocalTaskInput { title?: string; body: string; piece: PieceName; profile: ProfileName; outputFormat: OutputFormat; askPolicy: AskPolicy; priority: Priority; attachments?: Array<{ name: string; contentBase64: string }>; visibility?: Visibility; visibilityScopeOrgId?: string | null; browserSessionProfileId?: number | null; options?: { mcpDisabled?: boolean; skillsDisabled?: boolean; }; } export async function fetchLocalTasks(): Promise { const res = await fetch(`${BASE}/local/tasks`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks'); return data.tasks ?? []; } export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> { const res = await fetch(`${BASE}/local/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task'); return data; } export async function fetchLocalTask(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task'); return data.task; } export async function fetchLocalTaskComments(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments'); return data.comments ?? []; } export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise { const payload: Record = { body, author }; if (attachments && attachments.length > 0) payload.attachments = attachments; const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment'); } export async function updateLocalTask( taskId: number, updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null }, ): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task'); return data.task; } /** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */ export async function regenerateTaskTitle(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title'); return data.title as string; } export async function continueTaskWithPiece( taskId: number, body: { piece: string; instruction: string }, ): Promise<{ jobId: string }> { const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task'); return data; } export async function deleteLocalTask(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task'); } export async function cancelLocalTask(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, { method: 'POST', }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task'); } export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { const params = new URLSearchParams({ section }); if (path) params.set('path', path); const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); return data; } export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise { const params = new URLSearchParams({ section, path }); const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error ?? 'Failed to read file'); } return await res.text(); } export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { const params = new URLSearchParams({ section, path }); return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; } export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { const params = new URLSearchParams({ section, path, trusted: '1' }); return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; } export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ section, path, content }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } } // --- Config --- export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record }> { const res = await fetch(`${BASE}/config`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config'); return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} }; } export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> { const res = await fetch(`${BASE}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': etag }, body: JSON.stringify(config), }); const data = await res.json(); if (res.status === 409) return { ok: false, conflict: true }; if (!res.ok) throw new Error(data?.error ?? 'Failed to update config'); return data; } export async function reloadConfig(): Promise { const res = await fetch(`${BASE}/config/reload`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to reload config'); } // --- Pieces --- export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null } export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] } export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] } /** Full response from GET /api/pieces/:name — includes the server-resolved source. */ export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string } export async function fetchPieces(): Promise { const res = await fetch(`${BASE}/pieces`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces'); return data.pieces; } export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise { const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`; const res = await fetch(url); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece'); return { piece: data.piece, source: data.source, ownerId: data.ownerId }; } export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise { const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`; const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(piece), }); if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); } } export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' } export async function createPiece(piece: PieceDef): Promise { const res = await fetch(`${BASE}/pieces`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(piece), }); if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); } const d = await res.json(); return { source: d.source ?? 'user-custom' }; } export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise { const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`; const res = await fetch(url, { method: 'DELETE' }); if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); } } // --- Tools --- /** * Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by * `src/bridge/tools-api.ts` (server side). See design doc step 4: * docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md */ export interface ToolCatalogEntry { name: string; source: 'builtin' | 'meta' | 'mcp'; /** * Coarse grouping for UI. For builtin/meta tools this is a module name * (e.g. 'core', 'web'). For MCP tools the server uses `mcp:`. */ category: string; /** MCP server id (only set when source === 'mcp'). */ serverId?: string; /** Whether the tool can be invoked right now. */ available: boolean; /** Human-readable explanation when `available` is false. */ reason?: string; /** * - 'global' → meta tools auto-injected by the agent loop * - 'piece' → must be listed in a piece's `allowed_tools` * - 'user' → per-user resource (MCP / SSH) */ scope: 'global' | 'piece' | 'user'; } export async function fetchTools(): Promise { const res = await fetch(`${BASE}/tools`); if (!res.ok) throw new Error('Failed to fetch tools'); const data = (await res.json()) as { tools?: unknown }; if (!Array.isArray(data.tools)) return []; // Server may still occasionally serve the legacy flat-string shape (e.g. // during a transient mismatch / proxy / cache). Filter to only well-formed // catalog entries so the UI never crashes; legacy strings are dropped, the // piece editor will then surface them as "unknown" entries (visible+disabled // with a warning) once they appear in an existing piece's allowed_tools. return data.tools.filter( (t): t is ToolCatalogEntry => typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string', ); } // --- Subtasks --- export interface SubtaskFiles { files: string[]; categories: Record; } export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files'); return { files: data.files ?? [], categories: data.categories ?? {} }; } export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string { return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`; } export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise { const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath)); if (!res.ok) throw new Error('Failed to fetch subtask file content'); return res.text(); } export async function fetchSubtaskActivities(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); return data.subtasks ?? []; } export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity'); return data.activityLog ?? ''; } export async function putFeedback( taskId: number, feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string }, ): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feedback), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback'); return data.task; } // --- Share --- export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> { const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to share task'); return data; } export async function unshareTask(taskId: number): Promise { const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task'); } export async function fetchSharedTask(token: string): Promise { const res = await fetch(`${BASE}/shared/${token}`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Not found'); return data.task; } export async function fetchSharedTaskComments(token: string): Promise { const res = await fetch(`${BASE}/shared/${token}/comments`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments'); return data.comments ?? []; } export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { const params = new URLSearchParams(); if (path) params.set('path', path); const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); return data; } export async function fetchSharedFileContent(token: string, path: string): Promise { const params = new URLSearchParams({ path }); const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`); if (!res.ok) throw new Error('Failed to read file'); return res.text(); } export function getSharedFileRawUrl(token: string, path: string): string { const params = new URLSearchParams({ path }); return `${BASE}/shared/${token}/files/raw?${params.toString()}`; } export async function fetchSharedSubtaskActivities(token: string): Promise { const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); return data.subtasks ?? []; } // --- Browser Session Profiles --- export interface BrowserSessionProfile { id: number; label: string; startUrl: string; matchPatterns: string[]; storageOrigins: string[]; loggedInSelector: string | null; loginUrlPatterns: string[]; status: 'pending' | 'active' | 'expired' | 'revoked' | 'error'; stateVersion: number; lastSavedAt: string | null; lastUsedAt: string | null; lastValidatedAt: string | null; lastError: string | null; createdAt: string; updatedAt: string; } const SESS_BASE = `${BASE}/browser-sessions`; export async function listBrowserSessionProfiles(): Promise { const r = await fetch(`${SESS_BASE}/profiles`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`); return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles; } export async function createBrowserSessionProfile( input: Partial & { label: string; startUrl: string }, ): Promise { const r = await fetch(`${SESS_BASE}/profiles`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`); return (await r.json() as { profile: BrowserSessionProfile }).profile; } export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> { const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' }); if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`); return r.json(); } export async function saveBrowserSession(id: number, sessionId: string): Promise { const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId }), }); if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`); return (await r.json() as { profile: BrowserSessionProfile }).profile; } export async function cancelBrowserSession(id: number, sessionId: string): Promise { await fetch(`${SESS_BASE}/profiles/${id}/cancel`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId }), }); } export async function testBrowserSessionProfile(id: number): Promise<{ verdict: { expired: boolean; reason?: string }; finalUrl: string; statusCode: number; }> { const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' }); if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`); return r.json(); } export async function deleteBrowserSessionProfile(id: number): Promise { const r = await fetch(`${SESS_BASE}/profiles/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`); } // --- Reflection --- export interface LatestReflectionForTask { snapshotId: string; outcome: string; memoryChanges: number | null; pieceEdited: boolean; } export async function getLatestReflectionForTask( taskId: number, ): Promise { const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`); if (res.status === 404) return null; if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`); const data = await res.json(); // API returns null body when no reflection exists if (!data || !data.snapshotId) return null; return data as LatestReflectionForTask; } // --- User Folder Pets --- export interface PetSettings { enabled: boolean; activePetId: string | null; size: 32 | 48 | 64 | 80; position: 'bottom-right'; sound: boolean; reducedMotion: boolean; toolSparkEnabled: boolean; workerPets: Record; } export interface WorkerInfo { id: string; endpoint: string | null; model: string | null; roles: string[]; enabled: boolean; /** True if this worker fronts an LLM gateway / proxy (Phase A). */ proxy?: boolean; /** Proxy implementation; only 'litellm' is currently shipped. */ proxyType?: 'litellm'; } export async function fetchWorkers(): Promise { const res = await fetch('/api/workers', { credentials: 'include' }); if (!res.ok) return []; const data = await res.json() as { workers?: WorkerInfo[] }; return data.workers ?? []; } export interface BackendInfo { id: string; model: string | null; online: boolean; } export interface WorkerBackendsResponse { source: 'direct' | 'proxy'; proxyType?: 'litellm'; backends: BackendInfo[]; /** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */ error?: string; } export async function fetchWorkerBackends(workerId: string): Promise { const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, { credentials: 'include', }); if (!res.ok && res.status !== 502) { // 502 still carries a typed payload from the server. return { source: 'direct', backends: [], error: `HTTP ${res.status}` }; } return await res.json() as WorkerBackendsResponse; } export interface PetSummary { id: string; name: string; description: string | null; spriteFile: string | null; previewFile: string | null; frameWidth: number | null; frameHeight: number | null; gridCols: number | null; gridRows: number | null; updatedAt: string; } export interface PetDetail extends PetSummary { manifest: Record; } export interface PetsResponse { pets: PetSummary[]; settings: PetSettings; } const PETS_BASE = '/api/users/me/pets'; export function petAssetUrl(petId: string, file: string): string { return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`; } export async function fetchPets(): Promise { const res = await fetch(PETS_BASE, { credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets'); return data; } export async function fetchPet(petId: string): Promise { const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet'); return data.pet; } export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise { const params = new URLSearchParams(); params.set('filename', file.name); if (options.petId) params.set('petId', options.petId); if (options.overwrite) params.set('overwrite', 'true'); const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/zip' }, body: await file.arrayBuffer(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet'); return data.pet; } export async function deletePet(petId: string): Promise { const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { method: 'DELETE', credentials: 'include', }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet'); } export async function updatePetSettings(patch: Partial): Promise { const res = await fetch(`${PETS_BASE}/settings`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings'); return data.settings; } // ── Side Info Panel ──────────────────────────────────────────────────────── export type DashboardWidgetKind = 'markdown' | 'node-status'; export interface DashboardWidget { id: number; userId: string; slug: string; title: string; kind: DashboardWidgetKind; markdownContent: string; sortOrder: number; createdAt: string; updatedAt: string; } export interface NodeStatus { nodeId: string; workerId: string; source: 'direct' | 'proxy'; online: boolean; busy: boolean; busySlots: number; totalSlots: number; loadedModel: string | null; throughputTps: number | null; lastSeen: string; lastProbeError?: string; } export interface WorkerStatusBackendRow { id: string; state: 'idle' | 'running'; busySlots: number; totalSlots: number; online: boolean | null; } export interface WorkerStatusRow { id: string; name: string; roles: string[]; state: 'idle' | 'running'; /** True when this row represents a `proxy: true` worker. */ proxy: boolean; /** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */ busySlots?: number; totalSlots?: number; online?: boolean; /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */ backends?: WorkerStatusBackendRow[]; } export async function fetchDashboardWidgets(): Promise { const res = await fetch('/api/local/dashboard/widgets'); if (!res.ok) throw new Error(`Failed to list dashboard widgets: ${res.status}`); const body = await res.json(); return body.widgets; } export async function createDashboardWidget(input: { slug: string; title: string; content?: string; kind?: DashboardWidgetKind; }): Promise { const res = await fetch('/api/local/dashboard/widgets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) throw new Error(`Failed to create widget: ${res.status} ${await res.text()}`); return (await res.json()).widget; } export async function updateDashboardWidget(id: number, patch: { title?: string; content?: string; }): Promise { const res = await fetch(`/api/local/dashboard/widgets/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!res.ok) throw new Error(`Failed to update widget: ${res.status} ${await res.text()}`); return (await res.json()).widget; } export async function deleteDashboardWidget(id: number): Promise { const res = await fetch(`/api/local/dashboard/widgets/${id}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) throw new Error(`Failed to delete widget: ${res.status}`); } export async function reorderDashboardWidgets(ids: number[]): Promise { const res = await fetch('/api/local/dashboard/widgets/reorder', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }); if (!res.ok) throw new Error(`Failed to reorder widgets: ${res.status}`); } export async function fetchWorkerStatuses(): Promise { const res = await fetch('/api/local/dashboard/workers'); if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`); return (await res.json()).workers; } /** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */ export class NodeStatusUnavailableError extends Error { constructor() { super('node-status registry not configured'); this.name = 'NodeStatusUnavailableError'; } } export async function fetchNodeStatus(): Promise { const res = await fetch('/api/local/dashboard/node-status'); if (!res.ok) { // 503 = registry not configured (e.g. legacy install). Surface as an // error so the React Query hook can back off polling instead of // hammering the server every 5s indefinitely. The hook turns this // into an empty list for rendering. if (res.status === 503) throw new NodeStatusUnavailableError(); throw new Error(`Failed to list node status: ${res.status}`); } return (await res.json()).nodes; } // ── AAO Gateway: virtual key admin (Phase 2a + 2b) ────────────────────── // // Talks to /api/admin/gateway/keys/* — requires admin role. The raw // bearer key is returned ONCE from POST / and POST /:id/rotate; UI must // surface it to the user immediately and not expose it again. export interface GatewayKey { id: string; object: 'gateway.key'; keyPrefix: string; team: string; allowedModels: string[] | null; source: 'admin' | 'config-import'; createdAt: string; createdBy: string | null; revokedAt: string | null; revokedBy: string | null; lastUsedAt: string | null; tokensBudget: number | null; rateLimitRpm: number | null; /** Only present on POST / rotate responses. */ key?: string; } export interface GatewayKeyUsageResponse { keyId: string; currentPeriod: string; tokensIn: number; tokensOut: number; tokensTotal: number; tokensBudget: number | null; remaining: number | null; requestsThisMonth: number; rateLimitRpm: number | null; // Phase 3a F9: `rateRecentRequests` removed. The field was always // null because the admin process doesn't own the gateway's live // RateLimiter. Phase 3b/3c may re-add it via gateway IPC. history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>; } export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise { const q = new URLSearchParams(); if (params?.team) q.set('team', params.team); if (params?.activeOnly) q.set('activeOnly', 'true'); const qs = q.toString(); const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`); if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`); return (await res.json()).keys; } export async function createGatewayKey(input: { team: string; allowedModels?: string[]; tokensBudget?: number | null; rateLimitRpm?: number | null; }): Promise { const res = await fetch(`/api/admin/gateway/keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to create gateway key (${res.status}): ${text}`); } return res.json(); } export async function getGatewayKey(id: string): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`); if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`); return res.json(); } export async function patchGatewayKey( id: string, patch: { tokensBudget?: number | null; rateLimitRpm?: number | null; allowedModels?: string[] | null; }, ): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to update gateway key (${res.status}): ${text}`); } return res.json(); } export async function revokeGatewayKey(id: string): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`); } } export async function rotateGatewayKey(id: string): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`); } return res.json(); } export async function deleteGatewayKey(id: string): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to delete gateway key (${res.status}): ${text}`); } } export async function getGatewayKeyUsage(id: string): Promise { const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`); if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`); return res.json(); } // ============================================================ // Gateway Server status (Phase 3c) — read-only admin endpoint. // Drives the Settings → Gateway Server badge and error list. // ============================================================ export type GatewayServerState = | 'unavailable' | 'disabled' | 'starting' | 'running' | 'stopping' | 'misconfigured'; export interface GatewayServerStatus { state: GatewayServerState; /** Desired-enabled flag read from current config. Null when no ConfigManager. */ enabled: boolean | null; /** Validation errors that prevented the gateway from starting. */ errors: string[]; mounted: boolean; sharedPort: number; /** Only present when state==='unavailable'. */ message?: string; } export async function getGatewayServerStatus(): Promise { const res = await fetch('/api/admin/gateway/status'); if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`); return res.json(); } // ── Skills API ────────────────────────────────────────────────────────── export interface SkillSummary { name: string; description: string; triggers: string[]; source: 'system' | 'user'; hasDir: boolean; } export interface SkillDetail extends SkillSummary { content: string; files: string[]; findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>; maxSeverity: 'high' | 'medium' | 'none'; } export async function fetchSkills(scope?: string): Promise { const params = scope ? `?scope=${scope}` : ''; const res = await fetch(`/api/skills${params}`); if (!res.ok) throw new Error('Failed to fetch skills'); const data = await res.json(); return data.skills; } export async function fetchSkillDetail(name: string, scope?: string): Promise { const params = scope ? `?scope=${scope}` : ''; const res = await fetch(`/api/skills/${encodeURIComponent(name)}${params}`); if (!res.ok) throw new Error('Skill not found'); return res.json(); } export async function createSkill(name: string, content: string, scope: string): Promise<{ name: string; severity: string; findings: any[] }> { const res = await fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, content, scope }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error); } return res.json(); } export async function updateSkill(name: string, content: string, scope: string): Promise { const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error); } return res.json(); } export async function deleteSkill(name: string, scope: string): Promise { const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, { method: 'DELETE' }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error); } } export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[]): Promise { const res = await fetch('/api/skills/install-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, scope, selectedSkills }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error); } return res.json(); } // ── Notifications V2 (Web Push) ─────────────────────────────────────── export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; export interface NotificationPrefsDTO { userId: string; enabled: boolean; events: Record; includeDetails: boolean; v1Migrated: boolean; updatedAt: string; } export interface NotificationPrefsInput { enabled?: boolean; events?: Partial>; includeDetails?: boolean; } export interface PushSubscriptionPublic { id: string; endpointHost: string; userAgent: string | null; createdAt: string; lastSuccessAt: string | null; lastFailureAt: string | null; failureCount: number; } export interface VapidPublicKeyDTO { publicKey: string; keyId: string; } async function notificationsJsonOrThrow(res: Response, fallback: string): Promise { const data = await res.json().catch(() => ({} as Record)); if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback); return data as T; } export async function fetchVapidPublicKey(): Promise { const res = await fetch(`${BASE}/notifications/vapid-public-key`); return notificationsJsonOrThrow(res, 'failed to fetch VAPID key'); } export async function listPushSubscriptions(): Promise { const res = await fetch(`${BASE}/notifications/subscriptions`); const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>( res, 'failed to list subscriptions', ); return data.subscriptions; } export async function postPushSubscription(input: { endpoint: string; p256dh: string; auth: string; userAgent?: string; }): Promise<{ id: string }> { const res = await fetch(`${BASE}/notifications/subscriptions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); return notificationsJsonOrThrow(res, 'failed to register subscription'); } export async function deletePushSubscription(id: string): Promise { const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' }); await notificationsJsonOrThrow(res, 'failed to delete subscription'); } export async function fetchNotificationPrefs(): Promise { const res = await fetch(`${BASE}/notifications/preferences`); return notificationsJsonOrThrow(res, 'failed to fetch preferences'); } export async function updateNotificationPrefs( input: NotificationPrefsInput, ): Promise { const res = await fetch(`${BASE}/notifications/preferences`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); return notificationsJsonOrThrow(res, 'failed to update preferences'); } export async function migrateLocalStoragePrefs( input: NotificationPrefsInput, ): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> { const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (res.status === 409) return { alreadyMigrated: true }; return notificationsJsonOrThrow(res, 'failed to migrate preferences'); } export async function postTestNotification(): Promise<{ ok: boolean }> { const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' }); return notificationsJsonOrThrow(res, 'failed to send test notification'); } // ============================================================ // LLM usage dashboard (per-user, gateway + direct). // Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md export interface UsageCounters { tokensIn: number; tokensOut: number; requests: number; } export interface UsageBucket { bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM' gateway: UsageCounters; direct: UsageCounters; } export interface UsageByUser extends UsageCounters { userId: string; /** Resolved display name (real users); 'local' / 'system' for sentinels. */ displayName: string; } export interface UsageDailyResponse { from: string; to: string; granularity: 'day' | 'week' | 'month'; scope: 'all' | 'self'; series: UsageBucket[]; totals: { gateway: UsageCounters; direct: UsageCounters }; byUser?: UsageByUser[]; // admin / local mode only } export async function getUsageDaily(params: { from?: string; to?: string; granularity?: 'day' | 'week' | 'month'; }): Promise { const qs = new URLSearchParams(); if (params.from) qs.set('from', params.from); if (params.to) qs.set('to', params.to); if (params.granularity) qs.set('granularity', params.granularity); const q = qs.toString(); const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`); if (!res.ok) throw new Error(`Failed to load usage (${res.status})`); return res.json(); }