// SDK client wrapper for React Native // We create our own lightweight client that mirrors the opencode SDK patterns // but works in React Native environment // expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE import { fetch as expoFetch } from "expo/fetch" import { buildRequestHeaders } from "./headers" import { SSEParser } from "./sse" export interface ClientConfig { baseUrl: string directory?: string auth?: { username: string password: string } } export interface Session { id: string slug: string projectID: string directory: string parentID?: string title: string version: string share?: { url: string } time: { created: number updated: number compacting?: number archived?: number } summary?: { additions: number deletions: number files: number } } export interface Message { id: string sessionID: string role: "user" | "assistant" parentID?: string time: { created: number completed?: number } // User message fields agent?: string model?: { providerID: string; modelID: string } // Assistant message fields modelID?: string providerID?: string cost?: number tokens?: { input: number output: number reasoning?: number cache?: { read: number; write: number } } error?: { message: string } finish?: string } // API returns messages with parts embedded export interface MessageWithParts { info: Message parts: Part[] } export interface Part { id: string sessionID?: string messageID: string type: | "text" | "reasoning" | "tool" | "file" | "snapshot" | "patch" | "step-start" | "step-finish" | "subtask" | "retry" | "compaction" | "agent" // Text / reasoning part text?: string // Tool part tool?: string callID?: string state?: { status: "pending" | "running" | "completed" | "error" input?: unknown output?: unknown title?: string error?: { message: string } time?: { start?: number; end?: number } } // Timing time?: { start?: number; end?: number } // File part mime?: string url?: string filename?: string } export interface Agent { name: string description?: string mode: "subagent" | "primary" | "all" native?: boolean hidden?: boolean topP?: number temperature?: number color?: string model?: { modelID: string; providerID: string } prompt?: string options: Record steps?: number } export interface Command { name: string description?: string agent?: string model?: string mcp?: boolean template: string subtask?: boolean hints: string[] } export interface Project { id: string name?: string path: { cwd: string root: string absolute: string } } export interface Event { type: string properties: Record } export interface HealthResponse { healthy: boolean version: string } const REQUEST_TIMEOUT_MS = 30_000 function createHeaders(config: ClientConfig): HeadersInit { return buildRequestHeaders(config) } async function request(config: ClientConfig, path: string, options: RequestInit = {}): Promise { const url = `${config.baseUrl}${path}` const headers = { ...createHeaders(config), ...options.headers } const response = await fetchWithTimeout(url, { ...options, headers, }) if (!response.ok) { const error = await response.text() throw new Error(`API Error: ${response.status} - ${error}`) } return response.json() } async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { const parentSignal = options.signal if (parentSignal?.aborted) throw new Error("Request aborted") const controller = new AbortController() let timedOut = false const timeout = setTimeout(() => { timedOut = true controller.abort() }, REQUEST_TIMEOUT_MS) const onParentAbort = () => controller.abort() parentSignal?.addEventListener("abort", onParentAbort) try { return await fetch(url, { ...options, signal: controller.signal }) } catch (error) { if (timedOut) { throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`) } throw error } finally { clearTimeout(timeout) parentSignal?.removeEventListener("abort", onParentAbort) } } export function createClient(config: ClientConfig) { return { global: { health: () => request(config, "/global/health"), // SSE event stream - returns async iterator // Pass an AbortSignal to cancel the connection async *events(signal?: AbortSignal): AsyncGenerator { const url = `${config.baseUrl}/global/event` const headers = createHeaders(config) // Remove Content-Type for SSE (it's text/event-stream) delete (headers as Record)["Content-Type"] // Must use expo/fetch for ReadableStream support on native const response = await expoFetch(url, { headers, signal }) if (!response.ok || !response.body) { throw new Error(`Failed to connect to event stream: ${response.status}`) } const reader = response.body.getReader() const decoder = new TextDecoder() const parser = new SSEParser() try { while (true) { const { done, value } = await reader.read() if (done) break for (const data of parser.push(decoder.decode(value, { stream: true }))) { try { yield JSON.parse(data) } catch (err) { console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err) } } } } finally { reader.releaseLock() } }, }, project: { list: () => request(config, "/project"), current: () => request(config, "/project/current"), }, path: { get: () => request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"), }, session: { list: (params?: { roots?: boolean; limit?: number; search?: string }) => { const query = new URLSearchParams() if (params?.roots) query.set("roots", "true") if (params?.limit) query.set("limit", String(params.limit)) if (params?.search) query.set("search", params.search) const qs = query.toString() return request(config, `/session${qs ? `?${qs}` : ""}`) }, get: (sessionID: string) => request(config, `/session/${sessionID}`), create: (params?: { title?: string }) => request(config, "/session", { method: "POST", body: JSON.stringify(params || {}), }), delete: (sessionID: string) => request(config, `/session/${sessionID}`, { method: "DELETE" }), update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) => request(config, `/session/${sessionID}`, { method: "PATCH", body: JSON.stringify(params), }), messages: (sessionID: string, params?: { limit?: number }) => { const query = new URLSearchParams() if (params?.limit) query.set("limit", String(params.limit)) const qs = query.toString() return request(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`) }, // Sends a message and returns the response // Fire-and-forget async prompt - SSE events drive all real-time updates prompt: async ( sessionID: string, params: { parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }> model?: { providerID: string; modelID: string } agent?: string variant?: string }, ): Promise => { const url = `${config.baseUrl}/session/${sessionID}/prompt_async` const headers = createHeaders(config) const body = JSON.stringify(params) const response = await fetchWithTimeout(url, { method: "POST", headers, body, }) if (!response.ok) { const error = await response.text() throw new Error(`Failed to send message: ${response.status} - ${error}`) } }, command: async ( sessionID: string, params: { command: string arguments: string agent?: string model?: string variant?: string parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }> }, ): Promise => { const url = `${config.baseUrl}/session/${sessionID}/command` const headers = createHeaders(config) const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify({ ...params, sessionID }), }) if (!response.ok) { const error = await response.text() throw new Error(`Failed to run command: ${response.status} - ${error}`) } }, abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }), diff: (sessionID: string, messageID?: string) => { const qs = messageID ? `?messageID=${messageID}` : "" return request(config, `/session/${sessionID}/diff${qs}`) }, }, permission: { list: () => request>(config, "/permission"), reply: (requestID: string, reply: "once" | "always" | "reject") => request(config, `/permission/${requestID}/reply`, { method: "POST", body: JSON.stringify({ reply }), }), }, question: { list: () => request>(config, "/question"), reply: (requestID: string, answers: string[][]) => request(config, `/question/${requestID}/reply`, { method: "POST", body: JSON.stringify({ answers }), }), reject: (requestID: string) => request(config, `/question/${requestID}/reject`, { method: "POST", }), }, agent: { list: () => request(config, "/agent"), }, command: { list: () => request(config, "/command"), }, provider: { list: () => request<{ all: Array<{ id: string name: string models: Record< string, { id: string name: string attachment: boolean reasoning: boolean tool_call: boolean cost?: { input: number; output: number } limit: { context: number; output: number } status?: "alpha" | "beta" | "deprecated" | "active" } > }> default: Record connected: string[] }>(config, "/provider"), }, config: { get: () => request(config, "/config"), }, } } export type Client = ReturnType