/** * HTTP layer for `DaemonClient` — typed wrappers around fetch + envelope * unwrap. All paths concatenate `baseUrl + apiPrefix + route`. */ import type { FsBrowseResponse, FsHomeResponse, } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; import type { AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; import type { UpdateSessionProfileRequest as SessionUpdate } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; import type { ProviderCatalogItem, SetDefaultModelResponse, } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; import type { ApprovalResponse } from '@moonshot-ai/kap-server/protocol/approval'; import type { Envelope } from '@moonshot-ai/kap-server/protocol/envelope'; import type { Message } from '@moonshot-ai/kap-server/protocol/message'; import type { QuestionResponse } from '@moonshot-ai/kap-server/protocol/question'; import type { ApprovalResolveResult, ListPendingApprovalsResponse, } from '@moonshot-ai/kap-server/protocol/rest-approval'; import type { ListModelsResponse, ListProvidersResponse, } from '@moonshot-ai/kap-server/protocol/rest-modelCatalog'; import type { PromptAbortResponse, PromptListResponse, PromptSubmission, PromptSteerResult, PromptSubmitResult, } from '@moonshot-ai/kap-server/protocol/rest-prompt'; import type { ListPendingQuestionsResponse, QuestionResolveResult, } from '@moonshot-ai/kap-server/protocol/rest-question'; import type { CompactSessionRequest, CompactSessionResponse, ForkSessionRequest, SessionAbortResponse, UndoSessionRequest, UndoSessionResponse, } from '@moonshot-ai/kap-server/protocol/rest-session'; import type { CloseTerminalResponse, CreateTerminalRequest, ListTerminalsResponse, } from '@moonshot-ai/kap-server/protocol/rest-terminal'; import type { Session, SessionChildCreate, SessionCreate, } from '@moonshot-ai/kap-server/protocol/session'; import type { Workspace, WorkspaceCreate, WorkspaceUpdate, } from '@moonshot-ai/kap-server/protocol/workspace'; import { unwrap } from './envelope.js'; import { fetchWithReport, recordReportEvent } from './report.js'; export interface HttpClientOptions { baseUrl: string; apiPrefix: string; fetchImpl: typeof fetch; reportDir?: string; /** Optional bearer token — sent as `Authorization: Bearer ` when set. */ token?: string; } type UploadFileData = Blob | ArrayBuffer | Uint8Array | string; export class HttpClient { constructor(private readonly opts: HttpClientOptions) {} private url(path: string): string { return `${this.opts.baseUrl}${this.opts.apiPrefix}${path}`; } private async request( method: string, path: string, body: unknown, ): Promise { const startedAt = Date.now(); const headers: Record = { accept: 'application/json' }; if (this.opts.token !== undefined) { headers['authorization'] = `Bearer ${this.opts.token}`; } let init: RequestInit; if (body !== undefined) { headers['content-type'] = 'application/json'; init = { method, headers, body: JSON.stringify(body) }; } else { init = { method, headers }; } const url = this.url(path); let res: Response; let text = ''; try { res = await this.opts.fetchImpl(url, init); text = await res.text(); } catch (error) { recordReportEvent( { kind: 'http', method, path, url, durationMs: Date.now() - startedAt, request: requestForReport(body), error: errorForReport(error), }, { reportDir: this.opts.reportDir }, ); throw error; } let envelope: Envelope; try { envelope = JSON.parse(text) as Envelope; } catch (error) { recordReportEvent( { kind: 'http', method, path, url, status: res.status, durationMs: Date.now() - startedAt, request: requestForReport(body), response: { raw: text.slice(0, 2_000) }, error: errorForReport(error), }, { reportDir: this.opts.reportDir }, ); throw new Error( `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, { cause: error }, ); } recordReportEvent( { kind: 'http', method, path, url, status: res.status, durationMs: Date.now() - startedAt, request: requestForReport(body), response: { envelope }, }, { reportDir: this.opts.reportDir }, ); return unwrap(envelope); } private async formRequest( method: 'POST', path: string, body: FormData, ): Promise { const url = this.url(path); const res = await fetchWithReport( url, { method, headers: { accept: 'application/json' }, body, }, { fetchImpl: this.opts.fetchImpl, reportDir: this.opts.reportDir, path, }, ); const text = await res.text(); let envelope: Envelope; try { envelope = JSON.parse(text) as Envelope; } catch (error) { throw new Error( `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, { cause: error }, ); } return unwrap(envelope); } // ── Probes + model catalog ───────────────────────────────────────────── getAuth(): Promise { return this.request('GET', '/auth', undefined); } listModels(): Promise { return this.request('GET', '/models', undefined); } setDefaultModel(modelId: string): Promise { return this.request( 'POST', `/models/${encodeURIComponent(modelId)}:set_default`, {}, ); } listProviders(): Promise { return this.request('GET', '/providers', undefined); } getProvider(providerId: string): Promise { return this.request( 'GET', `/providers/${encodeURIComponent(providerId)}`, undefined, ); } // ── Sessions ──────────────────────────────────────────────────────────── createSession(body: SessionCreate): Promise { return this.request('POST', '/sessions', body); } getSession(sid: string): Promise { return this.request('GET', `/sessions/${encodeURIComponent(sid)}`, undefined); } listSessions(query?: { page_size?: number; before_id?: string; after_id?: string; workspace_id?: string; }): Promise<{ items: Session[]; has_more: boolean }> { return this.request('GET', `/sessions${qs(query)}`, undefined); } updateSession(sid: string, body: SessionUpdate): Promise { // Daemon canonical route: `POST /v1/sessions/{sid}/profile` (REST.md §3.3). // Earlier scaffolding spoke `PATCH /v1/sessions/{sid}`, which the server // never wired — keep the helper name (used by existing fixtures) and just // dispatch to the right URL. return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/profile`, body, ); } forkSession(sid: string, body: ForkSessionRequest = {}): Promise { return this.request('POST', `/sessions/${encodeURIComponent(sid)}:fork`, body); } compactSession( sid: string, body: CompactSessionRequest = {}, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}:compact`, body, ); } undoSession( sid: string, body: UndoSessionRequest = { count: 1 }, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}:undo`, body, ); } archiveSession(sid: string): Promise<{ archived: true }> { return this.request('POST', `/sessions/${encodeURIComponent(sid)}:archive`, {}); } listChildren( sid: string, query?: { page_size?: number; before_id?: string; after_id?: string; busy?: boolean }, ): Promise<{ items: Session[]; has_more: boolean }> { return this.request( 'GET', `/sessions/${encodeURIComponent(sid)}/children${qs(query)}`, undefined, ); } createChild(sid: string, body: SessionChildCreate = {}): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/children`, body, ); } // ── Terminals ────────────────────────────────────────────────────────── listTerminals(sid: string): Promise { return this.request( 'GET', `/sessions/${encodeURIComponent(sid)}/terminals`, undefined, ); } createTerminal( sid: string, body: CreateTerminalRequest = {}, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/terminals`, body, ); } getTerminal(sid: string, terminalId: string): Promise { return this.request( 'GET', `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}`, undefined, ); } closeTerminal( sid: string, terminalId: string, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}:close`, {}, ); } // ── Workspaces ────────────────────────────────────────────────────────── listWorkspaces(): Promise<{ items: Workspace[] }> { return this.request('GET', '/workspaces', undefined); } createWorkspace(body: WorkspaceCreate): Promise { return this.request('POST', '/workspaces', body); } updateWorkspace(workspaceId: string, body: WorkspaceUpdate): Promise { return this.request( 'PATCH', `/workspaces/${encodeURIComponent(workspaceId)}`, body, ); } deleteWorkspace(workspaceId: string): Promise<{ deleted: true }> { return this.request( 'DELETE', `/workspaces/${encodeURIComponent(workspaceId)}`, undefined, ); } // ── Folder picker (fs:browse + fs:home) ───────────────────────────────── fsBrowse(path?: string): Promise { return this.request('GET', `/fs:browse${qs({ path })}`, undefined); } fsHome(): Promise { return this.request('GET', '/fs:home', undefined); } // ── Uploads ───────────────────────────────────────────────────────────── uploadFile(input: { name: string; data: UploadFileData; mediaType?: string; expiresInSec?: number; }): Promise { const form = new FormData(); form.append('name', input.name); if (input.expiresInSec !== undefined) { form.append('expires_in_sec', String(input.expiresInSec)); } form.append('file', blobFromInput(input), input.name); return this.formRequest('POST', '/files', form); } deleteFile(fileId: string): Promise<{ deleted: true }> { return this.request('DELETE', `/files/${encodeURIComponent(fileId)}`, undefined); } // ── Messages ──────────────────────────────────────────────────────────── listMessages( sid: string, query?: { page_size?: number; before_id?: string; after_id?: string; role?: string }, ): Promise<{ items: Message[]; has_more: boolean }> { return this.request('GET', `/sessions/${encodeURIComponent(sid)}/messages${qs(query)}`, undefined); } // ── Prompts ───────────────────────────────────────────────────────────── listPrompts(sid: string): Promise { return this.request('GET', `/sessions/${encodeURIComponent(sid)}/prompts`, undefined); } submitPrompt(sid: string, body: PromptSubmission): Promise { return this.request('POST', `/sessions/${encodeURIComponent(sid)}/prompts`, body); } steerPrompt(sid: string, pid: string): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:steer`, {}, ); } steerPrompts(sid: string, promptIds: readonly string[]): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/prompts:steer`, { prompt_ids: [...promptIds] }, ); } abortPrompt(sid: string, pid: string): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:abort`, {}, ); } abortSession(sid: string): Promise { return this.request('POST', `/sessions/${encodeURIComponent(sid)}:abort`, {}); } // ── Approvals / Questions (reverse-RPC resolves) ──────────────────────── resolveApproval( sid: string, aid: string, body: ApprovalResponse, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/approvals/${encodeURIComponent(aid)}`, body, ); } listPendingApprovals(sid: string): Promise { return this.request( 'GET', `/sessions/${encodeURIComponent(sid)}/approvals?status=pending`, undefined, ); } resolveQuestion( sid: string, qid: string, body: QuestionResponse, ): Promise { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}`, body, ); } listPendingQuestions(sid: string): Promise { return this.request( 'GET', `/sessions/${encodeURIComponent(sid)}/questions?status=pending`, undefined, ); } dismissQuestion( sid: string, qid: string, ): Promise<{ dismissed: true; dismissed_at: string }> { return this.request( 'POST', `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}:dismiss`, {}, ); } } function requestForReport(body: unknown): { body?: unknown } { return body === undefined ? {} : { body }; } function errorForReport(error: unknown): unknown { if (error instanceof Error) { return { name: error.name, message: error.message, }; } return error; } function qs(query: Record | undefined): string { if (!query) return ''; const parts: string[] = []; for (const [k, v] of Object.entries(query)) { if (v === undefined) continue; parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(serializedQueryValue(v))}`); } return parts.length > 0 ? `?${parts.join('&')}` : ''; } function serializedQueryValue(value: string | number | boolean): string { if (typeof value === 'string') return value; if (typeof value === 'number') return value.toString(); return value ? 'true' : 'false'; } function blobFromInput(input: { data: UploadFileData; mediaType?: string; }): Blob { if (input.data instanceof Blob) return input.data; return new Blob([input.data], { type: input.mediaType ?? 'application/octet-stream', }); }