// apps/kimi-web/src/api/daemon/client.ts // DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs. import type { KimiApiConfig } from '../config'; import { buildRestUrl, buildWsUrl } from '../config'; import type { AppConfig, AppMessage, AppMessageRole, AppModel, AppProvider, ProviderRefreshResult, AppSession, AppSkill, AppSessionCursor, AppSessionRuntimeStatus, AppSessionSnapshot, AppSessionStatus, AppTask, AppTaskStatus, AppTerminal, AppWorkspace, ApprovalResponse, FsBrowseResult, FsEntry, KimiEventConnection, KimiEventHandlers, KimiWebApi, Page, PageRequest, PromptSubmission, PromptSubmitResult, QuestionResponse, } from '../types'; import { createAgentProjector } from './agentEventProjector'; import { DaemonHttpClient } from './http'; import { toAppApprovalRequest, toAppConfig, toAppEvent, toAppFsEntry, toAppMessage, toAppModel, toAppProvider, toAppQuestionRequest, toAppSession, toAppTask, toWireApprovalResponse, toWirePromptSubmission, toWireQuestionResponse, toWireSessionStatus, toAppWorkspace, wireEventSeq, wireEventSessionId, } from './mappers'; import type { WireAuthResult, WireBackgroundTask, WireConfig, WireEvent, WireFileMeta, WireFsBrowseResult, WireFsEntry, WireFsHomeResult, WireMessage, WireModel, WireOAuthCancelResult, WireOAuthLoginPollResult, WireOAuthLoginStartResult, WirePage, WirePromptSubmitResult, WirePromptSteerResult, WireProvider, WireProviderRefreshResult, WireSession, WireSessionAbortResult, WireSessionWarning, WireSessionWarningsResponse, WireSessionRuntimeStatus, WireSessionSnapshot, WireWorkspace, WireLogoutResult, } from './wire'; import { DaemonEventSocket } from './ws'; // --------------------------------------------------------------------------- // Wire response shapes for endpoints not in shared wire.ts // --------------------------------------------------------------------------- interface WireHealth { status: 'ok'; uptime_sec: number; } interface WireMeta { server_version: string; server_id: string; started_at: string; capabilities: Record; open_in_apps?: string[]; dangerous_bypass_auth?: boolean; } interface WireAbortResult { aborted: boolean; at_seq?: number; } interface WireDismissResult { dismissed: boolean; dismissed_at: string; } interface WireApprovalResolveResult { resolved: true; resolved_at: string; } interface WireQuestionResolveResult { resolved: true; resolved_at: string; } interface WireCancelResult { cancelled: true; } interface WireSkillDescriptor { name: string; description: string; path: string; source: string; type?: string; disable_model_invocation?: boolean; } interface WireArchiveResult { archived: true; } interface WireListDirectoryResult { items: WireFsEntry[]; children_by_path?: Record; truncated: boolean; } interface WireReadFileResult { path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; language_id?: string; line_count?: number; is_binary: boolean; } interface WireSearchFilesResult { items: Array<{ path: string; name: string; kind: 'file' | 'directory' | 'symlink'; score: number; match_positions: number[]; }>; truncated: boolean; } interface WireGrepFilesResult { files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[]; }>; }>; files_scanned: number; truncated: boolean; elapsed_ms: number; } interface WireGitStatusResult { branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest?: { number: number; state: string; url: string } | null; } interface WireDiffResult { path: string; diff: string; } interface WireTerminal { id: string; session_id: string; cwd: string; shell: string; cols: number; rows: number; status: 'running' | 'exited'; created_at: string; exited_at?: string; exit_code?: number | null; } function toAppTerminal(data: WireTerminal): AppTerminal { return { id: data.id, sessionId: data.session_id, cwd: data.cwd, shell: data.shell, cols: data.cols, rows: data.rows, status: data.status, createdAt: data.created_at, exitedAt: data.exited_at, exitCode: data.exit_code, }; } /** * historyCompacted reasons caused by compaction itself. These do NOT trigger a * snapshot reload: the client keeps the visible scrollback and renders a * divider marker instead. Every other reason (delta_gap, history_rewrite, …) * still means "cached messages are stale" and goes through onResync. */ function isCompactionReason(reason: string): boolean { return reason === 'auto_compact' || reason === 'manual_compact'; } // --------------------------------------------------------------------------- // DaemonKimiWebApi // --------------------------------------------------------------------------- export class DaemonKimiWebApi implements KimiWebApi { private readonly http: DaemonHttpClient; private readonly config: KimiApiConfig; constructor(config: KimiApiConfig) { this.config = config; this.http = new DaemonHttpClient(config.serverHttpUrl, { clientId: config.clientId, clientName: config.clientName, clientVersion: config.clientVersion, clientUiMode: config.clientUiMode, }); } // ------------------------------------------------------------------------- // Health / Meta // ------------------------------------------------------------------------- async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> { // Real daemon returns { ok: true }; the older shape was { status, uptime_sec }. const data = await this.http.get>('/healthz'); return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 }; } async getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[]; dangerousBypassAuth: boolean; }> { const data = await this.http.get('/meta'); return { serverVersion: data.server_version, serverId: data.server_id, startedAt: data.started_at, capabilities: data.capabilities, openInApps: Array.isArray(data.open_in_apps) ? data.open_in_apps : [], dangerousBypassAuth: data.dangerous_bypass_auth === true, }; } // ------------------------------------------------------------------------- // Sessions // ------------------------------------------------------------------------- async listSessions( input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean; }, ): Promise> { const query: Record = { before_id: input?.beforeId, after_id: input?.afterId, page_size: input?.pageSize, status: input?.status ? toWireSessionStatus(input.status) : undefined, include_archive: input?.includeArchive, exclude_empty: input?.excludeEmpty, // PRESUMED — daemon supports ?workspace_id= once the registry ships; it // ignores unknown query params until then, so this is safe to always send. workspace_id: input?.workspaceId, }; const data = await this.http.get>('/sessions', query); return { items: data.items.map(toAppSession), hasMore: data.has_more, }; } async createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string; }): Promise { // The real daemon requires `metadata` to be an object (rejects a missing // metadata with 40001), so always send it — with cwd when provided. const body: Record = { metadata: input.cwd !== undefined ? { cwd: input.cwd } : {}, }; // PRESUMED — daemon resolves cwd from workspace_id once the registry ships. // We ALSO send metadata.cwd (above) as the fallback so today's daemon, which // only understands cwd, still creates the session in the right folder. if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId; if (input.title !== undefined) body['title'] = input.title; if (input.model !== undefined) body['agent_config'] = { model: input.model }; const data = await this.http.post('/sessions', body); return toAppSession(data); } // GET /sessions/{id} — fetch one session (deep links to sessions outside the // first listSessions page). async getSession(sessionId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}`, ); return toAppSession(data); } // The daemon has no PATCH on sessions; mutating title/metadata/agent_config // (model + runtime controls) goes through POST /sessions/{id}/profile with a // SessionUpdate body { title?, metadata?, agent_config? }. Runtime controls in // agent_config are dispatched to the matching core RPCs (setModel/setThinking/ // setPermission/enterPlan|cancelPlan); the live values are read back from // GET /sessions/{id}/status (the profile echo's agent_config can be stale/""). async updateSession( sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; }, ): Promise { const body: Record = {}; if (input.title !== undefined) body['title'] = input.title; if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd }; const agentConfig: Record = {}; if (input.model !== undefined) agentConfig['model'] = input.model; if (input.permissionMode !== undefined) agentConfig['permission_mode'] = input.permissionMode; if (input.planMode !== undefined) agentConfig['plan_mode'] = input.planMode; if (input.swarmMode !== undefined) agentConfig['swarm_mode'] = input.swarmMode; if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/profile`, body, ); return toAppSession(data); } /** * GET /sessions/{id}/status — the session's live runtime state (current model, * thinking level, permission mode, plan flag, and context-window usage). This * is the source of truth for the status line; Session.agent_config.model can * be "" on the read path. */ async getSessionStatus(sessionId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/status`, ); return { model: data.model && data.model.length > 0 ? data.model : null, thinkingEffort: data.thinking_level, permission: data.permission, planMode: data.plan_mode === true, swarmMode: data.swarm_mode === true, contextTokens: data.context_tokens ?? 0, maxContextTokens: data.max_context_tokens ?? 0, contextUsage: data.context_usage ?? 0, }; } async getSessionWarnings(sessionId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/warnings`, ); return data.warnings ?? []; } async archiveSession(sessionId: string): Promise<{ archived: true }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}:archive`, {}, ); return data; } // ------------------------------------------------------------------------- // Messages // ------------------------------------------------------------------------- async listMessages( sessionId: string, input?: PageRequest & { role?: AppMessageRole }, ): Promise> { const query: Record = { before_id: input?.beforeId, after_id: input?.afterId, page_size: input?.pageSize, role: input?.role, }; const data = await this.http.get>( `/sessions/${encodeURIComponent(sessionId)}/messages`, query, ); return { items: data.items.map(toAppMessage), hasMore: data.has_more, }; } /** * v2 initial sync: atomic session state at an `as_of_seq` watermark. * Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor). */ async getSessionSnapshot(sessionId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/snapshot`, ); return { asOfSeq: data.as_of_seq, epoch: data.epoch, session: toAppSession(data.session), // Snapshot messages are already chronological ascending. messages: data.messages.items.map(toAppMessage), hasMoreMessages: data.messages.has_more, inFlightTurn: data.in_flight_turn === null ? null : { turnId: data.in_flight_turn.turn_id, assistantText: data.in_flight_turn.assistant_text, thinkingText: data.in_flight_turn.thinking_text, runningTools: data.in_flight_turn.running_tools.map((t) => ({ toolCallId: t.tool_call_id, name: t.name, args: t.args, description: t.description, lastProgress: t.last_progress, })), promptId: data.in_flight_turn.current_prompt_id, }, pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), pendingQuestions: data.pending_questions.map(toAppQuestionRequest), }; } // ------------------------------------------------------------------------- // Prompt // ------------------------------------------------------------------------- async submitPrompt( sessionId: string, input: PromptSubmission, ): Promise { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/prompts`, toWirePromptSubmission(input), ); return { promptId: data.prompt_id, userMessageId: data.user_message_id, status: data.status, }; } // POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the // active turn (TUI ctrl+s). Throws PROMPT_NOT_FOUND when there is no active // turn anymore (the queued prompt then starts its own turn — callers may // treat that as success). async steerPrompts( sessionId: string, promptIds: string[], ): Promise<{ steered: boolean; promptIds: string[] }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/prompts:steer`, { prompt_ids: promptIds }, ); return { steered: data.steered, promptIds: data.prompt_ids }; } async abortPrompt( sessionId: string, promptId: string, ): Promise<{ aborted: boolean; atSeq?: number }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`, undefined, { allowCodes: [40903] }, ); // data.aborted is false when 40903 (prompt already completed) — that's correct return { aborted: data.aborted, atSeq: data.at_seq }; } // POST /sessions/{id}:abort — cancel whatever is running in the session, // including skill activations that bypass IPromptService. async abortSession(sessionId: string): Promise<{ aborted: boolean }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}:abort`, {}, ); return { aborted: data.aborted }; } // POST /sessions/{id}:compact — request history compaction. Returns {}; // progress and completion arrive via the WS compaction.* events (the // transcript itself is not reloaded — a divider marker is appended). async compactSession(sessionId: string, instruction?: string): Promise { await this.http.post( `/sessions/${encodeURIComponent(sessionId)}:compact`, instruction ? { instruction } : {}, ); } // POST /sessions/{id}:undo — remove the last `count` turns from history. The // response carries the resulting messages + status, but we re-sync the session // afterwards for the authoritative (un-paginated) transcript, so we only need // the call to succeed here. async undoSession(sessionId: string, count = 1): Promise { await this.http.post( `/sessions/${encodeURIComponent(sessionId)}:undo`, { count }, ); } // POST /sessions/{id}:fork — fork the session into a new child session. async forkSession(sessionId: string, input?: { title?: string }): Promise { const body: Record = {}; if (input?.title !== undefined) body['title'] = input.title; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}:fork`, body, ); return toAppSession(data); } // POST /sessions/{id}/children — create a child ("side chat") session. The // daemon forks the parent (so the child inherits its context) and tags it with // parent_session_id + child_session_kind. async createChildSession(sessionId: string, input?: { title?: string }): Promise { const body: Record = {}; if (input?.title !== undefined) body['title'] = input.title; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/children`, body, ); return toAppSession(data); } // GET /sessions/{id}/children — list a session's child sessions. async listChildSessions(sessionId: string): Promise { const data = await this.http.get>( `/sessions/${encodeURIComponent(sessionId)}/children`, ); return data.items.map(toAppSession); } // POST /sessions/{id}:btw — start a TUI-style side-channel agent. Follow-up // prompts use the returned agent_id on the normal /prompts route. async startBtw(sessionId: string): Promise<{ agentId: string }> { const data = await this.http.post<{ agent_id: string }>( `/sessions/${encodeURIComponent(sessionId)}:btw`, {}, ); return { agentId: data.agent_id }; } // ------------------------------------------------------------------------- // Approval / Question // ------------------------------------------------------------------------- async respondApproval( sessionId: string, approvalId: string, response: ApprovalResponse, ): Promise<{ resolved: true; resolvedAt: string }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, toWireApprovalResponse(response), ); return { resolved: data.resolved, resolvedAt: data.resolved_at }; } async respondQuestion( sessionId: string, questionId: string, response: QuestionResponse, ): Promise<{ resolved: true; resolvedAt: string }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`, toWireQuestionResponse(response), ); return { resolved: data.resolved, resolvedAt: data.resolved_at }; } async dismissQuestion( sessionId: string, questionId: string, ): Promise<{ dismissed: true; dismissedAt: string }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`, undefined, { allowCodes: [40909] }, ); // 40909 means question.dismissed — that's the success path per spec return { dismissed: true, dismissedAt: data.dismissed_at }; } // ------------------------------------------------------------------------- // Tasks // ------------------------------------------------------------------------- async listTasks(sessionId: string, status?: AppTaskStatus): Promise { const query: Record = { status: status, }; const data = await this.http.get<{ items: WireBackgroundTask[] }>( `/sessions/${encodeURIComponent(sessionId)}/tasks`, query, ); return data.items.map(toAppTask); } async getTask( sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }, ): Promise { const query: Record = { with_output: input?.withOutput, output_bytes: input?.outputBytes, }; const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`, query, ); return toAppTask(data); } async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`, ); return data; } async listTerminals(sessionId: string): Promise { const data = await this.http.get<{ items: WireTerminal[] }>( `/sessions/${encodeURIComponent(sessionId)}/terminals`, ); return data.items.map(toAppTerminal); } async createTerminal( sessionId: string, input: { cwd?: string; shell?: string; cols?: number; rows?: number } = {}, ): Promise { const body: Record = { cwd: input.cwd, shell: input.shell, cols: input.cols, rows: input.rows, }; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/terminals`, body, ); return toAppTerminal(data); } async getTerminal(sessionId: string, terminalId: string): Promise { const data = await this.http.get( `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}`, ); return toAppTerminal(data); } async closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }> { return this.http.post<{ closed: true }>( `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}:close`, ); } // ------------------------------------------------------------------------- // Skills — slash-invocable skills (session- or workspace-scoped) // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } // GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session) // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } // ------------------------------------------------------------------------- async listSkills(sessionId: string): Promise { const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( `/sessions/${encodeURIComponent(sessionId)}/skills`, ); return (data.skills ?? []).map((s) => ({ name: s.name, description: s.description, source: s.source, })); } async listSkillsForWorkspace(workspaceId: string): Promise { const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( `/workspaces/${encodeURIComponent(workspaceId)}/skills`, ); return (data.skills ?? []).map((s) => ({ name: s.name, description: s.description, source: s.source, })); } async activateSkill( sessionId: string, skillName: string, args?: string, ): Promise<{ activated: true; skillName: string }> { const data = await this.http.post<{ activated: true; skill_name: string }>( `/sessions/${encodeURIComponent(sessionId)}/skills/${encodeURIComponent(skillName)}:activate`, args !== undefined && args.length > 0 ? { args } : {}, ); return { activated: data.activated, skillName: data.skill_name }; } // ------------------------------------------------------------------------- // File System // ------------------------------------------------------------------------- async listDirectory( sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }, ): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean; }> { const body: Record = {}; if (input.path !== undefined) body['path'] = input.path; if (input.depth !== undefined) body['depth'] = input.depth; if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:list`, body, ); const childrenByPath = data.children_by_path ? Object.fromEntries( Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]), ) : undefined; return { items: data.items.map(toAppFsEntry), childrenByPath, truncated: data.truncated, }; } async readFile( sessionId: string, input: { path: string; offset?: number; length?: number }, ): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean; }> { const body: Record = { path: input.path }; if (input.offset !== undefined) body['offset'] = input.offset; if (input.length !== undefined) body['length'] = input.length; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:read`, body, ); return { path: data.path, content: data.content, encoding: data.encoding, size: data.size, truncated: data.truncated, etag: data.etag, mime: data.mime, languageId: data.language_id, lineCount: data.line_count, isBinary: data.is_binary, }; } async searchFiles( sessionId: string, input: { query: string; limit?: number }, ): Promise<{ items: Array<{ path: string; name: string; kind: 'file' | 'directory' | 'symlink'; score: number; matchPositions: number[]; }>; truncated: boolean; }> { const body: Record = { query: input.query }; if (input.limit !== undefined) body['limit'] = input.limit; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:search`, body, ); return { items: data.items.map((item) => ({ path: item.path, name: item.name, kind: item.kind, score: item.score, matchPositions: item.match_positions, })), truncated: data.truncated, }; } async grepFiles( sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, ): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[]; }>; }>; filesScanned: number; truncated: boolean; elapsedMs: number; }> { const body: Record = { pattern: input.pattern }; if (input.regex !== undefined) body['regex'] = input.regex; if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:grep`, body, ); return { files: data.files, filesScanned: data.files_scanned, truncated: data.truncated, elapsedMs: data.elapsed_ms, }; } async getGitStatus( sessionId: string, paths?: string[], ): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }> { const body: Record = {}; if (paths !== undefined) body['paths'] = paths; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:git_status`, body, ); return { branch: data.branch, ahead: data.ahead, behind: data.behind, entries: data.entries, additions: data.additions, deletions: data.deletions, pullRequest: data.pullRequest ?? null, }; } async getFileDiff( sessionId: string, path: string, ): Promise<{ path: string; diff: string }> { const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/fs:diff`, { path }, ); return { path: data.path, diff: data.diff }; } getFileDownloadUrl(sessionId: string, path: string): string { const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/'); return buildRestUrl( this.config.serverHttpUrl, `/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`, ); } async openFile( sessionId: string, input: { path: string; line?: number }, ): Promise<{ opened: true }> { const body: Record = { path: input.path }; if (input.line !== undefined) body['line'] = input.line; return this.http.post<{ opened: true }>( `/sessions/${encodeURIComponent(sessionId)}/fs:open`, body, ); } async revealFile( sessionId: string, input: { path: string }, ): Promise<{ revealed: true }> { return this.http.post<{ revealed: true }>( `/sessions/${encodeURIComponent(sessionId)}/fs:reveal`, { path: input.path }, ); } async openInApp( sessionId: string, appId: string, path: string, line?: number, ): Promise { const body: Record = { app_id: appId, path }; if (line !== undefined) body['line'] = line; await this.http.post<{ opened: true }>( `/sessions/${encodeURIComponent(sessionId)}/fs:open-in`, body, ); } // ------------------------------------------------------------------------- // Workspaces + daemon folder browser // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. // ------------------------------------------------------------------------- /** * List the registered workspaces. * PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and * the composable DERIVES workspaces from the current sessions' cwds. So the * switcher + grouping work immediately off existing sessions until the daemon * ships the registry. */ async listWorkspaces(): Promise { try { const data = await this.http.get>('/workspaces'); return (data.items ?? []).map(toAppWorkspace); } catch { return []; } } /** * Register a workspace by folder path. * PRESUMED — POST /api/v1/workspaces { root, name? }. Throws on error (e.g. * path not found) so the caller can surface it to the user. */ async addWorkspace(input: { root: string; name?: string }): Promise { const body: Record = { root: input.root }; if (input.name !== undefined) body['name'] = input.name; const data = await this.http.post('/workspaces', body); return toAppWorkspace(data); } /** * Remove a registered workspace. * PRESUMED — DELETE /api/v1/workspaces/:id. On error this throws. */ async deleteWorkspace(id: string): Promise { await this.http.delete(`/workspaces/${encodeURIComponent(id)}`); } /** * Rename a workspace (display name only). * PATCH /api/v1/workspaces/:id { name }. On error this throws. */ async updateWorkspace(id: string, input: { name: string }): Promise { const data = await this.http.patch( `/workspaces/${encodeURIComponent(id)}`, { name: input.name }, ); return toAppWorkspace(data); } /** * Browse directories under `path` (defaults to $HOME on the daemon). * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so * the picker can distinguish "browse failed" from "directory has no children". */ async browseFs(path?: string): Promise { try { const data = await this.http.get('/fs:browse', { path }); return { path: data.path, parent: data.parent, entries: (data.entries ?? []).map((e) => ({ name: e.name, path: e.path, isDir: e.is_dir, isGitRepo: e.is_git_repo, branch: e.branch, })), }; } catch { return { path: '', parent: null, entries: [] }; } } /** * Get the picker start directory + recently-used roots. * PRESUMED — GET /api/v1/fs:home. On error returns empty defaults. */ async getFsHome(): Promise<{ home: string; recentRoots: string[] }> { try { const data = await this.http.get('/fs:home'); return { home: data.home, recentRoots: data.recent_roots ?? [] }; } catch { return { home: '', recentRoots: [] }; } } // ------------------------------------------------------------------------- // Models + Providers // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. // ------------------------------------------------------------------------- async listModels(): Promise { // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } const data = await this.http.get<{ items: WireModel[] }>('/models'); return data.items.map(toAppModel); } async listProviders(): Promise { // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } const data = await this.http.get<{ items: WireProvider[] }>('/providers'); return data.items.map(toAppProvider); } async addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string; }): Promise { // PRESUMED endpoint: POST /v1/providers → WireProvider const body: Record = { type: input.type }; if (input.apiKey !== undefined) body['api_key'] = input.apiKey; if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; const data = await this.http.post('/providers', body); return toAppProvider(data); } async deleteProvider(id: string): Promise<{ deleted: true }> { // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); } async refreshProvider(id: string): Promise { const data = await this.http.post( `/providers/${encodeURIComponent(id)}:refresh`, ); return toProviderRefreshResult(data); } async refreshAllProviders(): Promise { const data = await this.http.post('/providers:refresh'); return toProviderRefreshResult(data); } async refreshOAuthProviderModels(): Promise { const data = await this.http.post('/providers:refresh_oauth'); return toProviderRefreshResult(data); } // ------------------------------------------------------------------------- // Config — REAL endpoints // ------------------------------------------------------------------------- async getConfig(): Promise { const data = await this.http.get('/config'); return toAppConfig(data); } async setConfig(patch: Partial): Promise { const wirePatch: Record = {}; const keyMap: Record = { providers: 'providers', defaultProvider: 'default_provider', defaultModel: 'default_model', models: 'models', thinking: 'thinking', planMode: 'plan_mode', yolo: 'yolo', defaultPermissionMode: 'default_permission_mode', defaultPlanMode: 'default_plan_mode', permission: 'permission', hooks: 'hooks', services: 'services', mergeAllAvailableSkills: 'merge_all_available_skills', extraSkillDirs: 'extra_skill_dirs', loopControl: 'loop_control', background: 'background', experimental: 'experimental', telemetry: 'telemetry', raw: 'raw', }; for (const [key, value] of Object.entries(patch)) { const wireKey = keyMap[key as keyof AppConfig]; if (wireKey !== undefined) { wirePatch[wireKey] = value; } } const data = await this.http.post('/config', wirePatch); return toAppConfig(data); } // ------------------------------------------------------------------------- // Auth — REAL endpoints // ------------------------------------------------------------------------- async getAuth(): Promise<{ ready: boolean; providersCount: number; defaultModel: string | null; managedProvider: { status: string } | null; }> { const data = await this.http.get('/auth'); return { ready: data.ready, providersCount: data.providers_count, defaultModel: data.default_model, managedProvider: data.managed_provider ? { status: data.managed_provider.status } : null, }; } async startOAuthLogin(): Promise<{ flowId: string; provider: string; verificationUri: string; verificationUriComplete: string; userCode: string; expiresIn: number; interval: number; status: 'pending'; expiresAt: string; }> { const data = await this.http.post('/oauth/login', {}); return { flowId: data.flow_id, provider: data.provider, verificationUri: data.verification_uri, verificationUriComplete: data.verification_uri_complete, userCode: data.user_code, expiresIn: data.expires_in, interval: data.interval, status: data.status, expiresAt: data.expires_at, }; } async pollOAuthLogin(): Promise<{ flowId: string; status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; resolvedAt?: string; } | null> { // data may be null if no flow is active const data = await this.http.get('/oauth/login'); if (!data) return null; return { flowId: data.flow_id, status: data.status, resolvedAt: data.resolved_at, }; } async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { const data = await this.http.delete('/oauth/login'); return { cancelled: data.cancelled, status: data.status }; } async logout(): Promise<{ loggedOut: boolean }> { const data = await this.http.post('/oauth/logout', {}); return { loggedOut: data.logged_out }; } // ------------------------------------------------------------------------- // File upload // ------------------------------------------------------------------------- async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> { const formData = new FormData(); formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload')); if (input.name !== undefined) { formData.append('name', input.name); } const data = await this.http.postForm('/files', formData); return { id: data.id, name: data.name, mediaType: data.media_type, size: data.size, }; } getFileUrl(fileId: string): string { return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`); } /** Fetch a file's bytes with the Bearer credential attached. Use this (not * getFileUrl) when the bytes feed a