import type { AttachmentMetadata, AuthResponse, CodexLocalSessionsResponse, CodexDuplicateSessionsResponse, CodexMergeDuplicateSessionsResponse, CodexDesktopScriptResponse, CodexDesktopSyncRequest, CodexDesktopStatusResponse, CodexArchiveSessionResponse, CodexCollaborationMode, FileSearchResponse, MachinesResponse, MessagesResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, PushVapidPublicKeyResponse, SlashCommandsResponse, SkillsResponse, SpawnResponse, VisibilityPayload, HapiSessionExport, SessionResponse, SessionsResponse } from '@/types/api' import type { CodexModelsResponse, CursorMigrateOutcome, CursorMigrateToAcpRequest, CursorChatStoreStatus, CursorModelsResponse, DeleteUploadResponse, FileReadResponse, GitCommandResponse, GrokModelsResponse, GrokReasoningEffortResponse, ListDirectoryResponse, MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, OpencodeReasoningEffortResponse, QueuedStateResponse, ReopenSessionResponse, SqliteStorageUsageResponse, UsageSummaryResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor } from '@hapi/protocol' import type { CancelMessageResponse } from '@hapi/protocol/schemas' type ApiClientOptions = { baseUrl?: string getToken?: () => string | null onUnauthorized?: () => Promise } type ErrorPayload = { error?: unknown code?: unknown } function parseErrorCode(bodyText: string): string | undefined { try { const parsed = JSON.parse(bodyText) as ErrorPayload if (typeof parsed.code === 'string') return parsed.code if (typeof parsed.error === 'string') return parsed.error return undefined } catch { return undefined } } export class ApiError extends Error { status: number code?: string body?: string constructor(message: string, status: number, code?: string, body?: string) { super(message) this.name = 'ApiError' this.status = status this.code = code this.body = body } } export class ApiClient { private token: string private readonly baseUrl: string | null private readonly getToken: (() => string | null) | null private readonly onUnauthorized: (() => Promise) | null constructor(token: string, options?: ApiClientOptions) { this.token = token this.baseUrl = options?.baseUrl ?? null this.getToken = options?.getToken ?? null this.onUnauthorized = options?.onUnauthorized ?? null } private buildUrl(path: string): string { if (!this.baseUrl) { return path } try { return new URL(path, this.baseUrl).toString() } catch { return path } } private async request( path: string, init?: RequestInit, attempt: number = 0, overrideToken?: string | null ): Promise { const headers = new Headers(init?.headers) const liveToken = this.getToken ? this.getToken() : null const authToken = overrideToken !== undefined ? (overrideToken ?? (liveToken ?? this.token)) : (liveToken ?? this.token) if (authToken) { headers.set('authorization', `Bearer ${authToken}`) } if (init?.body !== undefined && !headers.has('content-type')) { headers.set('content-type', 'application/json') } const res = await fetch(this.buildUrl(path), { ...init, headers }) if (res.status === 401) { if (attempt === 0 && this.onUnauthorized) { const refreshed = await this.onUnauthorized() if (refreshed) { this.token = refreshed return await this.request(path, init, attempt + 1, refreshed) } } throw new Error('Session expired. Please sign in again.') } if (!res.ok) { const body = await res.text().catch(() => '') const code = parseErrorCode(body) throw new ApiError( `HTTP ${res.status} ${res.statusText}: ${body}`, res.status, code, body || undefined ) } return await res.json() as T } async authenticate(auth: { initData: string } | { accessToken: string }): Promise { const res = await fetch(this.buildUrl('/api/auth'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(auth) }) if (!res.ok) { const body = await res.text().catch(() => '') const code = parseErrorCode(body) const detail = body ? `: ${body}` : '' throw new ApiError(`Auth failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) } return await res.json() as AuthResponse } async bind(auth: { initData: string; accessToken: string }): Promise { const res = await fetch(this.buildUrl('/api/bind'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(auth) }) if (!res.ok) { const body = await res.text().catch(() => '') const code = parseErrorCode(body) const detail = body ? `: ${body}` : '' throw new ApiError(`Bind failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) } return await res.json() as AuthResponse } async getSessions(): Promise { return await this.request('/api/sessions') } async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } async getClaudeCustomModels(): Promise<{ models: string[] }> { return await this.request<{ models: string[] }>('/api/claude/custom-models') } async subscribePushNotifications(payload: PushSubscriptionPayload): Promise { await this.request('/api/push/subscribe', { method: 'POST', body: JSON.stringify(payload) }) } async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise { // 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。 return await this.request('/api/codex/sync-session', { method: 'POST', ...(payload ? { body: JSON.stringify(payload) } : {}) }) } async getCodexSessions(cwd?: string | null, machineId?: string | null): Promise { const params = new URLSearchParams() if (cwd?.trim()) params.set('cwd', cwd.trim()) if (machineId?.trim()) params.set('machineId', machineId.trim()) const query = params.size ? `?${params.toString()}` : '' return await this.request(`/api/codex/sessions${query}`) } async archiveCodexSession(sessionId: string, machineId?: string | null): Promise { return await this.request('/api/codex/archive-session', { method: 'POST', body: JSON.stringify({ sessionId, machineId: machineId ?? undefined }) }) } async getCodexDesktopStatus(): Promise { return await this.request('/api/codex/status') } async getCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise { // 中文注释:重复会话检测只传本次用户勾选导入的 codexSessionId,避免把未选中的历史会话也纳入提示。 return await this.request('/api/codex/duplicate-sessions', { method: 'POST', body: JSON.stringify(payload) }) } async mergeCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise { // 中文注释:真正执行合并时沿用同一批选中 codexSessionId,保证检测范围与执行范围一致。 return await this.request('/api/codex/merge-duplicate-sessions', { method: 'POST', body: JSON.stringify(payload) }) } async restartCodexDesktop(): Promise { return await this.request('/api/codex/restart-desktop', { method: 'POST' }) } async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { await this.request('/api/push/subscribe', { method: 'DELETE', body: JSON.stringify(payload) }) } async setVisibility(payload: VisibilityPayload): Promise { await this.request('/api/visibility', { method: 'POST', body: JSON.stringify(payload) }) } async getSession(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/export`, { signal: options?.signal } ) } async getMessages( sessionId: string, options: { beforeSeq?: number | null beforeAt?: number | null afterSeq?: number | null afterAt?: number | null untilSeq?: number | null untilAt?: number | null epoch?: number | null limit?: number } ): Promise { const params = new URLSearchParams() if (options.beforeAt !== undefined && options.beforeAt !== null) { params.set('beforeAt', `${options.beforeAt}`) } if (options.beforeSeq !== undefined && options.beforeSeq !== null) { params.set('beforeSeq', `${options.beforeSeq}`) } if (options.afterAt !== undefined && options.afterAt !== null) { params.set('afterAt', `${options.afterAt}`) } if (options.afterSeq !== undefined && options.afterSeq !== null) { params.set('afterSeq', `${options.afterSeq}`) } if (options.untilAt !== undefined && options.untilAt !== null) { params.set('untilAt', `${options.untilAt}`) } if (options.untilSeq !== undefined && options.untilSeq !== null) { params.set('untilSeq', `${options.untilSeq}`) } if (options.epoch !== undefined && options.epoch !== null) { params.set('epoch', `${options.epoch}`) } if (options.limit !== undefined && options.limit !== null) { params.set('limit', `${options.limit}`) } const qs = params.toString() const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}` return await this.request(url) } async getGitStatus(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`) } async getGitDiffNumstat(sessionId: string, staged: boolean): Promise { const params = new URLSearchParams() params.set('staged', staged ? 'true' : 'false') return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-numstat?${params.toString()}`) } async getGitDiffFile(sessionId: string, path: string, staged?: boolean): Promise { const params = new URLSearchParams() params.set('path', path) if (staged !== undefined) { params.set('staged', staged ? 'true' : 'false') } return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-file?${params.toString()}`) } async searchSessionFiles(sessionId: string, query: string, limit?: number): Promise { const params = new URLSearchParams() if (query) { params.set('query', query) } if (limit !== undefined) { params.set('limit', `${limit}`) } const qs = params.toString() return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`) } async getGeneratedImageBlob(sessionId: string, imageId: string, attempt: number = 0, overrideToken?: string | null): Promise { const headers = new Headers() const liveToken = this.getToken ? this.getToken() : null const authToken = overrideToken !== undefined ? (overrideToken ?? (liveToken ?? this.token)) : (liveToken ?? this.token) if (authToken) { headers.set('authorization', `Bearer ${authToken}`) } const res = await fetch(this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/generated-images/${encodeURIComponent(imageId)}`), { headers }) if (res.status === 401 && attempt === 0 && this.onUnauthorized) { const refreshed = await this.onUnauthorized() if (refreshed) { this.token = refreshed return await this.getGeneratedImageBlob(sessionId, imageId, attempt + 1, refreshed) } } if (!res.ok) { throw new ApiError(`HTTP ${res.status}`, res.status, undefined, await res.text().catch(() => undefined)) } return await res.blob() } async readSessionFile(sessionId: string, path: string): Promise { const params = new URLSearchParams() params.set('path', path) return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`) } async listSessionDirectory(sessionId: string, path?: string): Promise { const params = new URLSearchParams() if (path) { params.set('path', path) } const qs = params.toString() return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/directory${qs ? `?${qs}` : ''}` ) } async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/upload`, { method: 'POST', body: JSON.stringify({ filename, content, mimeType }) }) } async deleteUploadFile(sessionId: string, path: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/upload/delete`, { method: 'POST', body: JSON.stringify({ path }) }) } async resumeSession(sessionId: string, opts?: { permissionMode?: string }): Promise { const response = await this.request<{ sessionId: string }>( `/api/sessions/${encodeURIComponent(sessionId)}/resume`, { method: 'POST', ...(opts?.permissionMode !== undefined && { body: JSON.stringify({ permissionMode: opts.permissionMode }) }) } ) return response.sessionId } async getCursorChatStoreStatus(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/cursor-chat-store` ) } async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', body: JSON.stringify({ text, localId: localId ?? undefined, attachments: attachments ?? undefined, scheduledAt: scheduledAt ?? undefined }) }) } async getQueuedState(sessionId: string, localIds: string[]): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/messages/queued-state`, { method: 'POST', body: JSON.stringify({ localIds }) } ) } async cancelMessage(sessionId: string, messageId: string): Promise { const response = await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { method: 'DELETE' } ) return response as CancelMessageResponse } async abortSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, { method: 'POST', body: JSON.stringify({}) }) } async archiveSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/archive`, { method: 'POST', body: JSON.stringify({}) }) } async reopenSession(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/reopen`, { method: 'POST', body: JSON.stringify({}) } ) } /** * Migrate a legacy stream-json Cursor session to ACP. See tiann/hapi#824. * * Refusals (e.g. running session, missing on-disk store, target collision) * are returned as structured `{ok: false, reason, message}` outcomes * rather than thrown - the UI surfaces the reason to the operator and the * underlying state on disk is unchanged. * * 401s trigger the same onUnauthorized refresh path as the shared * `request()` helper so an expired JWT silently re-auths instead of * hard-failing the migration dialog (Codex review #34 P2). */ async migrateCursorSessionToAcp(sessionId: string, body: CursorMigrateToAcpRequest = {}): Promise { const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp` const tryOnce = async (overrideToken: string | null): Promise => { const headers = new Headers({ 'content-type': 'application/json' }) const liveToken = this.getToken ? this.getToken() : null const authToken = overrideToken ?? liveToken ?? this.token if (authToken) { headers.set('authorization', `Bearer ${authToken}`) } return fetch(this.buildUrl(path), { method: 'POST', headers, body: JSON.stringify(body) }) } let res = await tryOnce(null) if (res.status === 401 && this.onUnauthorized) { const refreshed = await this.onUnauthorized() if (refreshed) { this.token = refreshed res = await tryOnce(refreshed) } } if (res.status === 401) { throw new Error('Session expired. Please sign in again.') } const text = await res.text() let parsed: CursorMigrateOutcome | null = null try { parsed = text ? JSON.parse(text) as CursorMigrateOutcome : null } catch { parsed = null } if (parsed && typeof parsed === 'object' && 'ok' in parsed) { return parsed } throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`) } async switchSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { method: 'POST', body: JSON.stringify({}) }) } async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, { method: 'POST', body: JSON.stringify({ mode }) }) } async setCollaborationMode(sessionId: string, mode: CodexCollaborationMode): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/collaboration-mode`, { method: 'POST', body: JSON.stringify({ mode }) }) } async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) }) } async setModelReasoningEffort(sessionId: string, modelReasoningEffort: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model-reasoning-effort`, { method: 'POST', body: JSON.stringify({ modelReasoningEffort }) }) } async setEffort(sessionId: string, effort: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/effort`, { method: 'POST', body: JSON.stringify({ effort }) }) } async setServiceTier(sessionId: string, serviceTier: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/service-tier`, { method: 'POST', body: JSON.stringify({ serviceTier }) }) } async approvePermission( sessionId: string, requestId: string, modeOrOptions?: 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan' | { mode?: 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan' allowTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' answers?: Record | Record } ): Promise { const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined ? { mode: modeOrOptions } : modeOrOptions await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: JSON.stringify(body) }) } async denyPermission( sessionId: string, requestId: string, options?: { decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' } ): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, { method: 'POST', body: JSON.stringify(options ?? {}) }) } async getMachines(): Promise { return await this.request('/api/machines') } /** Pass an empty string to clear the custom name and fall back to the hostname. */ async renameMachine(machineId: string, displayName: string): Promise { await this.request(`/api/machines/${encodeURIComponent(machineId)}`, { method: 'PATCH', body: JSON.stringify({ displayName }) }) } async getSqliteStorageUsage(): Promise { return await this.request('/api/storage/sqlite') } async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise { return await this.request(`/api/usage/summary?range=${encodeURIComponent(range)}`) } async listMachineDirectory( machineId: string, path: string ): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/list-directory`, { method: 'POST', body: JSON.stringify({ path }) } ) } async checkMachinePathsExists( machineId: string, paths: string[] ): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/paths/exists`, { method: 'POST', body: JSON.stringify({ paths }) } ) } async spawnSession( machineId: string, directory: string, agent?: AgentFlavor, model?: string, modelReasoningEffort?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, effort?: string, permissionMode?: PermissionMode, serviceTier?: 'fast' | 'standard', collaborationMode?: 'default' | 'plan' ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, permissionMode, serviceTier, collaborationMode }) }) } async getMachineCodexModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/codex-models` ) } async getSessionOpencodeModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/opencode-models` ) } async getSessionOpencodeReasoningEffortOptions(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/opencode-reasoning-effort-options` ) } async getSessionCursorModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/cursor-models` ) } /** Generic Pi session endpoint — replaces per-method wrappers. */ async callPiEndpoint(sessionId: string, path: string, init?: RequestInit): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/pi-${path}`, init ) } async getMachineCursorModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/cursor-models` ) } async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}` ) } async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` ) } async getSessionGrokModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/grok-models` ) } async getSessionGrokReasoningEffortOptions(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/grok-reasoning-effort-options` ) } async getSlashCommands(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` ) } async getSkills(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/skills` ) } async renameSession(sessionId: string, name: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'PATCH', body: JSON.stringify({ name }) }) } async deleteSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }) } /* * Scratchlist v2 (tiann/hapi#893). * * The hub is the durable store; localStorage is demoted to an * offline cache. Mutations return the canonical entry so optimistic * updates can reconcile with the hub-stamped `updatedAt`. */ async getScratchlist(sessionId: string): Promise<{ entries: Array<{ entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] }> }> { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist` ) } async uploadScratchlistAttachment( sessionId: string, filename: string, content: string, mimeType: string ): Promise<{ success: boolean attachment?: import('@hapi/protocol').ScratchlistAttachmentMetadata error?: string code?: string }> { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/upload`, { method: 'POST', body: JSON.stringify({ filename, content, mimeType }) } ) } async fetchScratchlistAttachmentBlob(sessionId: string, attachmentId: string): Promise { const headers = new Headers() const liveToken = this.getToken ? this.getToken() : null const authToken = liveToken ?? this.token if (authToken) { headers.set('authorization', `Bearer ${authToken}`) } const response = await fetch( this.buildUrl( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/attachments/${encodeURIComponent(attachmentId)}` ), { headers } ) if (!response.ok) { throw new ApiError(`Failed to fetch scratchlist attachment (${response.status})`, response.status) } return await response.blob() } async deleteScratchlistAttachment(sessionId: string, attachmentId: string): Promise { await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/attachments/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' } ) } async createScratchlistEntry( sessionId: string, body: { text: string entryId?: string createdAt?: number attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } ): Promise<{ entry: { entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } }> { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist`, { method: 'POST', body: JSON.stringify(body) } ) } async updateScratchlistEntry( sessionId: string, entryId: string, text: string ): Promise<{ entry: { entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } }> { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, { method: 'PUT', body: JSON.stringify({ text }) } ) } async deleteScratchlistEntry(sessionId: string, entryId: string): Promise { await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, { method: 'DELETE' } ) } async fetchVoiceToken(options?: { customAgentId?: string; customApiKey?: string; voiceId?: string }): Promise<{ allowed: boolean token?: string agentId?: string error?: string }> { return await this.request('/api/voice/token', { method: 'POST', body: JSON.stringify(options || {}) }) } async fetchVoices(): Promise<{ voices: Array<{ id: string; name: string; previewUrl: string; category: string }> }> { return await this.request('/api/voice/voices') } async sendVoiceTelemetry(event: { stage: string message: string sessionId?: string voiceId?: string language?: string details?: Record }): Promise { await this.request('/api/voice/telemetry', { method: 'POST', body: JSON.stringify(event) }) } /** Return the current auth token (for WebSocket query-param auth). */ getAuthToken(): string | null { return this.getToken ? this.getToken() : this.token } async fetchVoiceBackend(): Promise<{ backend: string; backends: string[] }> { return await this.request('/api/voice/backend') } async fetchQwenToken(): Promise<{ allowed: boolean wsUrl?: string error?: string }> { return await this.request('/api/voice/qwen-token', { method: 'POST', body: JSON.stringify({}) }) } async fetchGeminiToken(): Promise<{ allowed: boolean apiKey?: string wsUrl?: string baseUrl?: string error?: string }> { return await this.request('/api/voice/gemini-token', { method: 'POST', body: JSON.stringify({}) }) } }