import { realpathSync } from 'node:fs'; import { logger } from '../utils/logger'; export interface DecorateRequestInput { baseUrl: string; path: string; directory: string; } export interface DecoratedRequest { url: string; headers: Record; } export interface OpencodeClientOptions { baseUrl: string; directory: string; fetchImpl?: typeof fetch; } export interface OpencodeModelReference { providerID: string; modelID: string; } export interface OpencodeFilePartInput { type: 'file'; mime: string; filename?: string; url: string; } export interface OpencodeCommandInfo { name: string; description?: string; agent?: string; model?: string; source?: 'command' | 'mcp' | 'skill'; subtask?: boolean; hints: string[]; template?: unknown; } export interface ForkOpencodeSessionInput { messageID?: string; } export interface SummarizeOpencodeSessionInput { providerID: string; modelID: string; } export interface OpencodeCommandTextPartInput { type: 'text'; text: string; synthetic: true; } export type OpencodeCommandPartInput = | OpencodeCommandTextPartInput | OpencodeFilePartInput; export interface ExecuteOpencodeSessionCommandInput { command: string; arguments: string; agent?: string; model?: string; variant?: string; parts?: OpencodeCommandPartInput[]; } export interface SendOpencodeSessionMessageInput { text: string; system?: string; agent?: string; files?: OpencodeFilePartInput[]; model?: OpencodeModelReference; } export interface FindOpencodeFilesOptions { limit?: number; } export interface UpdateOpencodeSessionInput { title?: string; } export interface OpencodeSkillInfo { name: string; description?: string; location: string; content: string; } export interface RevertOpencodeSessionMessageInput { messageID: string; partID?: string; } export type OpencodePermissionReply = 'once' | 'always' | 'reject'; type OpencodeTextPartInput = { type: 'text'; text: string }; type OpencodeMessagePartInput = OpencodeTextPartInput | OpencodeFilePartInput; type OpencodeTextPartsPayload = { parts: OpencodeMessagePartInput[]; model?: OpencodeModelReference; system?: string; agent?: string; }; function buildTextPartsPayload(input: SendOpencodeSessionMessageInput) { const payload: OpencodeTextPartsPayload = { parts: [ { type: 'text', text: input.text, }, ...(input.files ?? []), ], }; if (input.model) { payload.model = input.model; } if (input.system) { payload.system = input.system; } if (input.agent) { payload.agent = input.agent; } return payload; } function summarizePromptPayload(sessionID: string, payload: OpencodeTextPartsPayload) { const fileParts = payload.parts.filter((part): part is OpencodeFilePartInput => part.type === 'file'); const textParts = payload.parts.filter((part): part is OpencodeTextPartInput => part.type === 'text'); return { sessionID, model: payload.model ? `${payload.model.providerID}/${payload.model.modelID}` : null, textLength: textParts.reduce((sum, part) => sum + part.text.length, 0), partCount: payload.parts.length, filePartCount: fileParts.length, fileMimes: fileParts.map((part) => part.mime), }; } type ResolveRealPath = (directory: string) => string; export function canonicalizeOpencodeDirectory( directory: string, resolveRealPath: ResolveRealPath = realpathSync.native, ): string { try { return resolveRealPath(directory); } catch { return directory; } } export function decorateOpencodeRequest( input: DecorateRequestInput, canonicalizeDirectory: (directory: string) => string = canonicalizeOpencodeDirectory, ): DecoratedRequest { const url = new URL(input.path, input.baseUrl); const directory = canonicalizeDirectory(input.directory); const encodedDirectory = encodeURIComponent(directory); if (!url.searchParams.has('directory')) { url.searchParams.set('directory', directory); } return { url: url.toString(), headers: { 'Content-Type': 'application/json', 'x-opencode-directory': encodedDirectory, }, }; } async function parseJsonResponse(response: Response): Promise { if (!response.ok) { throw new Error(`Runtime request failed: ${response.status} ${response.statusText}`); } if (response.status === 204) return undefined as T; return await response.json() as T; } export function createOpencodeClient(options: OpencodeClientOptions) { const fetchImpl = options.fetchImpl ?? fetch; const request = async (path: string, init?: RequestInit): Promise => { const decorated = decorateOpencodeRequest({ baseUrl: options.baseUrl, path, directory: options.directory, }); const requestUrl = new URL(decorated.url); logger.info('[opencode-client] Forwarding request to runtime', { method: init?.method ?? 'GET', pathname: requestUrl.pathname, }); return await parseJsonResponse(await fetchImpl(decorated.url, { ...init, headers: { ...decorated.headers, ...(init?.headers ?? {}), }, })); }; const findFilesPath = (query: string, options?: FindOpencodeFilesOptions) => { const params = new URLSearchParams({ query }); if (typeof options?.limit === 'number') { params.set('limit', String(options.limit)); } return `/find/file?${params.toString()}`; }; const fileContentPath = (filePath: string) => { const params = new URLSearchParams({ path: filePath }); return `/file/content?${params.toString()}`; }; const searchTextPath = (pattern: string) => { const params = new URLSearchParams({ pattern }); return `/find?${params.toString()}`; }; const opencodeCommandMethods = { listCommands: (): Promise => request('/command'), forkSession: ( sessionID: string, payload: ForkOpencodeSessionInput = {}, ): Promise => request(`/session/${encodeURIComponent(sessionID)}/fork`, { method: 'POST', body: JSON.stringify(payload), }), shareSession: (sessionID: string): Promise => request(`/session/${encodeURIComponent(sessionID)}/share`, { method: 'POST', }), unshareSession: (sessionID: string): Promise => request(`/session/${encodeURIComponent(sessionID)}/share`, { method: 'DELETE', }), summarizeSession: ( sessionID: string, payload: SummarizeOpencodeSessionInput, ): Promise => request(`/session/${encodeURIComponent(sessionID)}/summarize`, { method: 'POST', body: JSON.stringify(payload), }), executeSessionCommand: ( sessionID: string, payload: ExecuteOpencodeSessionCommandInput, ): Promise => request(`/session/${encodeURIComponent(sessionID)}/command`, { method: 'POST', body: JSON.stringify(payload), }), }; return { ...opencodeCommandMethods, listSkills: () => request('/skill'), listSessions: () => request('/session'), createSession: (payload: Record) => request('/session', { method: 'POST', body: JSON.stringify(payload), }), updateSession: (sessionID: string, payload: UpdateOpencodeSessionInput) => request(`/session/${encodeURIComponent(sessionID)}`, { method: 'PATCH', body: JSON.stringify(payload), }), deleteSession: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}`, { method: 'DELETE', }), abortSession: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}/abort`, { method: 'POST', }), getSessionDiff: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}/diff`), revertSessionMessage: (sessionID: string, payload: RevertOpencodeSessionMessageInput) => request(`/session/${encodeURIComponent(sessionID)}/revert`, { method: 'POST', body: JSON.stringify(payload), }), unrevertSession: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}/unrevert`, { method: 'POST', }), getSessionTodos: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}/todo`), getSessionStatuses: () => request>('/session/status'), listQuestions: () => request('/question'), replyQuestion: (requestID: string, answers: string[][]) => request(`/question/${encodeURIComponent(requestID)}/reply`, { method: 'POST', body: JSON.stringify({ answers }), }), rejectQuestion: (requestID: string) => request(`/question/${encodeURIComponent(requestID)}/reject`, { method: 'POST', }), getConfig: () => request>('/config'), listPermissions: () => request('/permission'), replyPermission: (requestID: string, reply: OpencodePermissionReply, message?: string) => request(`/permission/${encodeURIComponent(requestID)}/reply`, { method: 'POST', body: JSON.stringify({ reply, ...(message ? { message } : {}), }), }), getSessionMessages: (sessionID: string) => request(`/session/${encodeURIComponent(sessionID)}/message`), sendSessionMessage: (sessionID: string, payload: SendOpencodeSessionMessageInput) => request(`/session/${encodeURIComponent(sessionID)}/message`, { method: 'POST', body: JSON.stringify(buildTextPartsPayload(payload)), }), promptSessionAsync: (sessionID: string, payload: SendOpencodeSessionMessageInput) => { const requestPayload = buildTextPartsPayload(payload); logger.info('[opencode-client] Sending prompt_async payload', summarizePromptPayload(sessionID, requestPayload)); return request(`/session/${encodeURIComponent(sessionID)}/prompt_async`, { method: 'POST', body: JSON.stringify(requestPayload), }); }, getFileStatuses: () => request('/file/status'), findFiles: (query: string, options?: FindOpencodeFilesOptions) => request(findFilesPath(query, options)), searchText: (pattern: string) => request(searchTextPath(pattern)), getFileContent: (filePath: string) => request(fileContentPath(filePath)), }; }