import type { SessionsListInput, SessionsListOutput, SessionsCreateInput, SessionsCreateOutput, SessionsGetInput, SessionsGetOutput, SessionsSwitchAgentInput, SessionsSwitchAgentOutput, SessionsSwitchModelInput, SessionsSwitchModelOutput, SessionsPromptInput, SessionsPromptOutput, SessionsCompactInput, SessionsCompactOutput, SessionsWaitInput, SessionsWaitOutput, SessionsStageInput, SessionsStageOutput, SessionsClearInput, SessionsClearOutput, SessionsCommitInput, SessionsCommitOutput, SessionsContextInput, SessionsContextOutput, } from "./types" import { ClientError } from "./client-error" export interface ClientOptions { readonly baseUrl: string readonly fetch?: typeof globalThis.fetch readonly headers?: HeadersInit } export interface RequestOptions { readonly signal?: AbortSignal readonly headers?: HeadersInit } interface RequestDescriptor { readonly method: string readonly path: string readonly query?: Record readonly headers?: Record readonly body?: unknown readonly successStatus: number readonly declaredStatuses: ReadonlyArray readonly empty: boolean } export function make(options: ClientOptions) { const fetch = options.fetch ?? globalThis.fetch const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { const url = new URL(descriptor.path, options.baseUrl) for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) const headers = new Headers(options.headers) for (const [key, value] of Object.entries(descriptor.headers ?? {})) { if (value !== undefined && value !== null) headers.set(key, String(value)) } for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") return { url, init: { method: descriptor.method, signal: requestOptions?.signal, headers, body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body), } satisfies RequestInit, } } const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { try { const prepared = prepare(descriptor, requestOptions) return await fetch(prepared.url, prepared.init) } catch (cause) { throw new ClientError("Transport", { cause }) } } const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { if (descriptor.declaredStatuses.includes(response.status)) throw await json(response) try { await response.body?.cancel() } catch {} throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) } const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) return responseError(response, descriptor) if (descriptor.empty) { try { await response.body?.cancel() } catch {} return undefined as A } return (await json(response)) as A } const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ async *[Symbol.asyncIterator]() { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) await responseError(response, descriptor) if (!isContentType(response, "text/event-stream")) { try { await response.body?.cancel() } catch {} throw new ClientError("UnsupportedContentType") } if (response.body === null) throw new ClientError("MalformedResponse") const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = "" try { while (true) { let next try { next = await reader.read() } catch (cause) { throw new ClientError("Transport", { cause }) } buffer += decoder.decode(next.value, { stream: !next.done }) if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") const trailingCarriageReturn = !next.done && buffer.endsWith("\r") if (trailingCarriageReturn) buffer = buffer.slice(0, -1) buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") if (trailingCarriageReturn) buffer += "\r" if (next.done && buffer !== "") buffer += "\n\n" let boundary = buffer.indexOf("\n\n") while (boundary >= 0) { const block = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) const data = block .split("\n") .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : [])) .join("\n") if (data !== "") { try { yield JSON.parse(data) as A } catch (cause) { throw new ClientError("MalformedResponse", { cause }) } } boundary = buffer.indexOf("\n\n") } if (next.done) return } } finally { try { await reader.cancel() } catch {} reader.releaseLock() } }, }) return { sessions: { list: (input?: SessionsListInput, requestOptions?: RequestOptions) => request( { method: "GET", path: `/api/session`, query: { workspace: input?.workspace, limit: input?.limit, order: input?.order, search: input?.search, directory: input?.directory, project: input?.project, subpath: input?.subpath, cursor: input?.cursor, }, successStatus: 200, declaredStatuses: [400, 401], empty: false, }, requestOptions, ), create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionsCreateOutput }>( { method: "POST", path: `/api/session`, body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, successStatus: 200, declaredStatuses: [401, 400], empty: false, }, requestOptions, ).then((value) => value.data), get: (input: SessionsGetInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionsGetOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}`, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, }, requestOptions, ).then((value) => value.data), switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, body: { agent: input.agent }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, }, requestOptions, ), switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, body: { model: input.model }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, }, requestOptions, ), prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionsPromptOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, successStatus: 200, declaredStatuses: [409, 404, 400, 401], empty: false, }, requestOptions, ).then((value) => value.data), compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, successStatus: 204, declaredStatuses: [404, 503, 400, 401], empty: true, }, requestOptions, ), wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, successStatus: 204, declaredStatuses: [404, 503, 400, 401], empty: true, }, requestOptions, ), stage: (input: SessionsStageInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionsStageOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, body: { messageID: input.messageID, files: input.files }, successStatus: 200, declaredStatuses: [404, 500, 400, 401], empty: false, }, requestOptions, ).then((value) => value.data), clear: (input: SessionsClearInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, successStatus: 204, declaredStatuses: [404, 500, 400, 401], empty: true, }, requestOptions, ), commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) => request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, }, requestOptions, ), context: (input: SessionsContextInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionsContextOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, successStatus: 200, declaredStatuses: [404, 500, 400, 401], empty: false, }, requestOptions, ).then((value) => value.data), }, } } function appendQuery(params: URLSearchParams, key: string, value: unknown): void { if (value === undefined || value === null) return if (Array.isArray(value)) { for (const item of value) appendQuery(params, key, item) return } if (typeof value === "object") { for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item) return } params.append(key, String(value)) } async function json(response: Response): Promise { if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) { try { await response.body?.cancel() } catch {} throw new ClientError("UnsupportedContentType") } let text: string try { text = await response.text() } catch (cause) { throw new ClientError("Transport", { cause }) } if (text === "") throw new ClientError("MalformedResponse") try { return JSON.parse(text) } catch (cause) { throw new ClientError("MalformedResponse", { cause }) } } function isContentType(response: Response, expected: string) { return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected }