Files
ide/packages/opencode-bridge/ts/index.ts
T
2026-05-10 14:08:25 +00:00

350 lines
9.7 KiB
TypeScript

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<string, boolean>;
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<T = unknown>() {
return this.request<T>('/global/health');
}
async projects<T = unknown>() {
return this.request<T>('/project');
}
async currentProject<T = unknown>() {
return this.request<T>('/project/current');
}
async path<T = unknown>() {
return this.request<T>('/path');
}
async vcs<T = unknown>() {
return this.request<T>('/vcs');
}
async config<T = unknown>() {
return this.request<T>('/config');
}
async providers<T = unknown>() {
return this.request<T>('/provider');
}
async agents<T = unknown>() {
return this.request<T>('/agent');
}
async commands<T = unknown>() {
return this.request<T>('/command');
}
async sessions<T = unknown>() {
return this.request<T>('/session');
}
async createSession<T = unknown>(body: { parentID?: string; title?: string } = {}) {
return this.request<T>('/session', { method: 'POST', body });
}
async session<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}`);
}
async updateSession<T = unknown>(id: string, body: { title?: string }) {
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'PATCH', body });
}
async deleteSession<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
async sessionStatus<T = unknown>() {
return this.request<T>('/session/status');
}
async children<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/children`);
}
async todo<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/todo`);
}
async messages<T = unknown>(id: string, limit?: number) {
const query = limit ? `?limit=${encodeURIComponent(`${limit}`)}` : '';
return this.request<T>(`/session/${encodeURIComponent(id)}/message${query}`);
}
async message<T = unknown>(id: string, messageID: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/message/${encodeURIComponent(messageID)}`);
}
async prompt<T = unknown>(id: string, body: IOpenCodePromptBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/message`, { method: 'POST', body });
}
async promptAsync(id: string, body: IOpenCodePromptBody) {
return this.request<void>(`/session/${encodeURIComponent(id)}/prompt_async`, { method: 'POST', body });
}
async command<T = unknown>(id: string, body: IOpenCodeCommandBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/command`, { method: 'POST', body });
}
async shell<T = unknown>(id: string, body: IOpenCodeShellBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/shell`, { method: 'POST', body });
}
async abort<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/abort`, { method: 'POST' });
}
async diff<T = unknown>(id: string, messageID?: string) {
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : '';
return this.request<T>(`/session/${encodeURIComponent(id)}/diff${query}`);
}
async revert<T = unknown>(id: string, body: { messageID: string; partID?: string }) {
return this.request<T>(`/session/${encodeURIComponent(id)}/revert`, { method: 'POST', body });
}
async unrevert<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/unrevert`, { method: 'POST' });
}
async respondToPermission<T = unknown>(
sessionID: string,
permissionID: string,
body: { response: string; remember?: boolean },
) {
return this.request<T>(
`/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(permissionID)}`,
{ method: 'POST', body },
);
}
async findFiles<T = unknown>(query: { query: string; type?: 'file' | 'directory'; directory?: string; limit?: number }) {
return this.request<T>(`/find/file?${new URLSearchParams(stringifyQuery(query))}`);
}
async findText<T = unknown>(query: { pattern: string }) {
return this.request<T>(`/find?${new URLSearchParams(stringifyQuery(query))}`);
}
async fileContent<T = unknown>(path: string) {
return this.request<T>(`/file/content?${new URLSearchParams({ path })}`);
}
async fileStatus<T = unknown>() {
return this.request<T>('/file/status');
}
async *events(signal?: AbortSignal): AsyncGenerator<IOpenCodeEvent> {
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<T>(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<string, string> = {};
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<string, string | number | boolean | undefined>) => {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
result[key] = String(value);
}
}
return result;
};