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 openCodeBridgeToolNames = [ 'bash', 'read', 'write', 'edit', 'grep', 'glob', 'apply_patch', ] as const; export type TOpenCodeBridgeToolName = typeof openCodeBridgeToolNames[number]; export interface IOpenCodeBridgeConfigRenderOptions { bridgeUrlEnvName?: string; bridgeTokenEnvName?: string; } export const renderOpenCodeBridgeConfigContent = () => `${JSON.stringify({ $schema: 'https://opencode.ai/config.json', autoupdate: false, snapshot: false, permission: { lsp: 'deny', skill: 'deny', }, }, undefined, 2)}\n`; export const renderOpenCodeBridgeToolFiles = (options: IOpenCodeBridgeConfigRenderOptions = {}) => { const files: Record = {}; for (const toolName of openCodeBridgeToolNames) { files[`tools/${toolName}.js`] = renderOpenCodeBridgeToolFile(toolName, options); } return files; }; export const renderOpenCodeBridgeToolFile = ( toolName: TOpenCodeBridgeToolName, options: IOpenCodeBridgeConfigRenderOptions = {}, ) => { const bridgeUrlEnvName = options.bridgeUrlEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_URL'; const bridgeTokenEnvName = options.bridgeTokenEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_TOKEN'; const definition = openCodeBridgeToolDefinitions[toolName]; return `import { tool } from "@opencode-ai/plugin"; const bridgeUrlEnvName = ${JSON.stringify(bridgeUrlEnvName)}; const bridgeTokenEnvName = ${JSON.stringify(bridgeTokenEnvName)}; const forwardTool = async (toolName, args, context) => { const bridgeUrl = process.env[bridgeUrlEnvName]; const bridgeToken = process.env[bridgeTokenEnvName]; if (!bridgeUrl || !bridgeToken) { throw new Error("Git.Zone OpenCode tool bridge is not configured."); } const response = await fetch(new URL("/tool/" + encodeURIComponent(toolName), bridgeUrl), { method: "POST", headers: { "authorization": "Bearer " + bridgeToken, "content-type": "application/json", }, body: JSON.stringify({ args, context: { sessionID: context.sessionID, messageID: context.messageID, agent: context.agent, directory: context.directory, worktree: context.worktree, }, }), }); const responseText = await response.text(); if (!response.ok) { throw new Error(responseText || ("Git.Zone OpenCode tool bridge failed with HTTP " + response.status)); } return responseText ? JSON.parse(responseText) : ""; }; export default tool({ description: ${JSON.stringify(definition.description)}, args: { ${definition.args.map((arg) => ` ${arg.name}: ${arg.schema},`).join('\n')} }, async execute(args, context) { return forwardTool(${JSON.stringify(toolName)}, args, context); }, }); `; }; const openCodeBridgeToolDefinitions: Record; }> = { bash: { description: 'Execute a shell command on the selected remote Git.Zone workspace over SSH. The command runs on the remote host, not on the local proxy workspace. Use workdir for a remote working directory when needed.', args: [ { name: 'command', schema: 'tool.schema.string().describe("The command to execute on the remote host")' }, { name: 'timeout', schema: 'tool.schema.number().optional().describe("Optional timeout in milliseconds")' }, { name: 'workdir', schema: 'tool.schema.string().optional().describe("Remote working directory. Defaults to the selected remote project path.")' }, { name: 'description', schema: 'tool.schema.string().describe("Clear, concise description of what this command does in 5-10 words")' }, ], }, read: { description: 'Read a file or directory from the selected remote Git.Zone workspace. Paths may be remote absolute paths or paths relative to the selected remote project.', args: [ { name: 'filePath', schema: 'tool.schema.string().describe("Remote file or directory path to read")' }, { name: 'offset', schema: 'tool.schema.number().optional().describe("The line number to start reading from (1-indexed)")' }, { name: 'limit', schema: 'tool.schema.number().optional().describe("The maximum number of lines to read")' }, ], }, write: { description: 'Create or overwrite a file in the selected remote Git.Zone workspace.', args: [ { name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to write")' }, { name: 'content', schema: 'tool.schema.string().describe("The content to write to the remote file")' }, ], }, edit: { description: 'Modify an existing remote file using exact string replacement.', args: [ { name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to modify")' }, { name: 'oldString', schema: 'tool.schema.string().describe("The text to replace")' }, { name: 'newString', schema: 'tool.schema.string().describe("The replacement text")' }, { name: 'replaceAll', schema: 'tool.schema.boolean().optional().describe("Replace all occurrences of oldString")' }, ], }, grep: { description: 'Search remote file contents in the selected Git.Zone workspace using ripgrep on the remote host.', args: [ { name: 'pattern', schema: 'tool.schema.string().describe("The regex pattern to search for in file contents")' }, { name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory or file to search. Defaults to the selected remote project path.")' }, { name: 'include', schema: 'tool.schema.string().optional().describe("File glob to include, for example *.ts or *.{ts,tsx}")' }, ], }, glob: { description: 'Find remote files by glob pattern in the selected Git.Zone workspace using ripgrep on the remote host.', args: [ { name: 'pattern', schema: 'tool.schema.string().describe("The glob pattern to match remote files against")' }, { name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory to search. Defaults to the selected remote project path.")' }, ], }, apply_patch: { description: 'Apply a stripped-down file patch to the selected remote Git.Zone workspace. Patch paths are relative to the selected remote project unless absolute remote paths are supplied.', args: [ { name: 'patchText', schema: 'tool.schema.string().describe("The full patch text that describes all remote file changes to make")' }, ], }, }; 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; };