import * as plugins from './plugins.js'; export interface IOpenCodeClientOptions { baseUrl: string; username?: string; password?: string; fetch?: typeof fetch; } export interface IOpenCodeMessagePart { type: string; [key: string]: unknown; } export interface IOpenCodePromptBody { messageID?: string; model?: { providerID: string; modelID: string; }; agent?: string; noReply?: boolean; system?: string; tools?: Record; parts: IOpenCodeMessagePart[]; } export interface IOpenCodeCommandBody { messageID?: string; agent?: string; model?: { providerID: string; modelID: string; }; command: string; arguments?: string; } export interface IOpenCodeShellBody { agent: string; model?: { providerID: string; modelID: string; }; command: string; } export interface IOpenCodeEvent { type: string; id?: string; retry?: number; data?: unknown; raw: string; } export class OpenCodeHttpError extends Error { constructor( message: string, public readonly status: number, public readonly body: string, ) { super(message); } } export class OpenCodeServerClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; private readonly authorizationHeader?: string; constructor(options: IOpenCodeClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/g, ''); this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); if (options.username || options.password) { const username = options.username ?? 'opencode'; const password = options.password ?? ''; this.authorizationHeader = `Basic ${plugins.Buffer.from(`${username}:${password}`).toString('base64')}`; } } async health() { return this.request('/global/health'); } async projects() { return this.request('/project'); } async currentProject() { return this.request('/project/current'); } async path() { return this.request('/path'); } async vcs() { return this.request('/vcs'); } async config() { return this.request('/config'); } async providers() { return this.request('/provider'); } async agents() { return this.request('/agent'); } async commands() { return this.request('/command'); } async sessions() { return this.request('/session'); } async createSession(body: { parentID?: string; title?: string } = {}) { return this.request('/session', { method: 'POST', body }); } async session(id: string) { return this.request(`/session/${encodeURIComponent(id)}`); } async updateSession(id: string, body: { title?: string }) { return this.request(`/session/${encodeURIComponent(id)}`, { method: 'PATCH', body }); } async deleteSession(id: string) { return this.request(`/session/${encodeURIComponent(id)}`, { method: 'DELETE' }); } async sessionStatus() { return this.request('/session/status'); } async children(id: string) { return this.request(`/session/${encodeURIComponent(id)}/children`); } async todo(id: string) { return this.request(`/session/${encodeURIComponent(id)}/todo`); } async messages(id: string, limit?: number) { const query = limit ? `?limit=${encodeURIComponent(`${limit}`)}` : ''; return this.request(`/session/${encodeURIComponent(id)}/message${query}`); } async message(id: string, messageID: string) { return this.request(`/session/${encodeURIComponent(id)}/message/${encodeURIComponent(messageID)}`); } async prompt(id: string, body: IOpenCodePromptBody) { return this.request(`/session/${encodeURIComponent(id)}/message`, { method: 'POST', body }); } async promptAsync(id: string, body: IOpenCodePromptBody) { return this.request(`/session/${encodeURIComponent(id)}/prompt_async`, { method: 'POST', body }); } async command(id: string, body: IOpenCodeCommandBody) { return this.request(`/session/${encodeURIComponent(id)}/command`, { method: 'POST', body }); } async shell(id: string, body: IOpenCodeShellBody) { return this.request(`/session/${encodeURIComponent(id)}/shell`, { method: 'POST', body }); } async abort(id: string) { return this.request(`/session/${encodeURIComponent(id)}/abort`, { method: 'POST' }); } async diff(id: string, messageID?: string) { const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : ''; return this.request(`/session/${encodeURIComponent(id)}/diff${query}`); } async revert(id: string, body: { messageID: string; partID?: string }) { return this.request(`/session/${encodeURIComponent(id)}/revert`, { method: 'POST', body }); } async unrevert(id: string) { return this.request(`/session/${encodeURIComponent(id)}/unrevert`, { method: 'POST' }); } async respondToPermission( sessionID: string, permissionID: string, body: { response: string; remember?: boolean }, ) { return this.request( `/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(permissionID)}`, { method: 'POST', body }, ); } async findFiles(query: { query: string; type?: 'file' | 'directory'; directory?: string; limit?: number }) { return this.request(`/find/file?${new URLSearchParams(stringifyQuery(query))}`); } async findText(query: { pattern: string }) { return this.request(`/find?${new URLSearchParams(stringifyQuery(query))}`); } async fileContent(path: string) { return this.request(`/file/content?${new URLSearchParams({ path })}`); } async fileStatus() { return this.request('/file/status'); } async *events(signal?: AbortSignal): AsyncGenerator { const response = await this.fetchImpl(`${this.baseUrl}/event`, { headers: this.createHeaders(), signal, }); if (!response.ok) { throw await this.createHttpError(response, '/event'); } if (!response.body) { return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\n\n/); buffer = parts.pop() ?? ''; for (const part of parts) { const event = parseServerSentEvent(part); if (event) { yield event; } } } const finalEvent = parseServerSentEvent(buffer); if (finalEvent) { yield finalEvent; } } private async request(path: string, options: { method?: string; body?: unknown } = {}) { const response = await this.fetchImpl(`${this.baseUrl}${path}`, { method: options.method ?? 'GET', headers: this.createHeaders(options.body !== undefined), body: options.body === undefined ? undefined : JSON.stringify(options.body), }); if (!response.ok) { throw await this.createHttpError(response, path); } if (response.status === 204) { return undefined as T; } const text = await response.text(); if (!text) { return undefined as T; } return JSON.parse(text) as T; } private createHeaders(hasJsonBody = false) { const headers: Record = {}; if (hasJsonBody) { headers['content-type'] = 'application/json'; } if (this.authorizationHeader) { headers.authorization = this.authorizationHeader; } return headers; } private async createHttpError(response: Response, path: string) { const body = await response.text(); return new OpenCodeHttpError(`OpenCode request failed: ${response.status} ${path}`, response.status, body); } } export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => { const trimmed = raw.trim(); if (!trimmed) { return undefined; } const dataLines: string[] = []; let type = 'message'; let id: string | undefined; let retry: number | undefined; for (const line of trimmed.split(/\r?\n/)) { if (line.startsWith(':')) { continue; } const separator = line.indexOf(':'); const field = separator === -1 ? line : line.slice(0, separator); const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, ''); if (field === 'event') { type = value; } else if (field === 'data') { dataLines.push(value); } else if (field === 'id') { id = value; } else if (field === 'retry') { const retryNumber = Number(value); if (Number.isFinite(retryNumber)) { retry = retryNumber; } } } const dataText = dataLines.join('\n'); const data = parseJsonIfPossible(dataText); if (type === 'message' && data && typeof data === 'object' && 'type' in data) { type = String((data as { type: unknown }).type); } return { type, id, retry, data, raw }; }; const parseJsonIfPossible = (value: string) => { if (!value) { return undefined; } try { return JSON.parse(value) as unknown; } catch { return value; } }; const stringifyQuery = (query: Record) => { const result: Record = {}; for (const [key, value] of Object.entries(query)) { if (value !== undefined) { result[key] = String(value); } } return result; };