// Runs a machine on opencode through its local server and SDK. Each run gets // a fresh opencode session rooted in the run's sandbox, with the machine's // instructions as the system prompt and opencode's built-in tools switched // off. The machine's own tools are served to opencode over MCP from this // process (see ../mcp.ts), so they run with the run's sandbox like they do // under other harnesses. The session is deleted afterwards so prompts do not // accumulate in opencode's own storage. // // Models use opencode's "provider/model" ids, e.g. // "openrouter/thinkingmachines/inkling:free". Runs use a custom opencode // agent whose prompt replaces opencode's coding prompt, and the server runs // with a private config home so the user's opencode config stays out. // // @opencode-ai/sdk is an optional peer dependency, and the SDK starts the // `opencode` binary, which must be on PATH. import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { rmSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Harness, HarnessRequest } from "../harness.ts"; import type { HarnessResult, Message } from "../types.ts"; import { serveToolsOverMcp } from "../mcp.ts"; import { withTranscript } from "./transcript.ts"; type Result = { data?: T; error?: unknown }; type OpencodePart = | { type: "text"; text: string; synthetic?: boolean } | { type: "tool"; callID: string; tool: string; state: { status: string; input?: unknown; output?: string; error?: string } } | { type: "step-finish"; reason?: string } | { type: string }; type OpencodeMessage = { info: { role?: string; error?: { name?: string; data?: { message?: string } }; tokens?: { input?: number; output?: number }; finish?: string; }; parts: OpencodePart[]; }; /** The subset of the opencode SDK client this harness uses. */ export type OpencodeClient = { session: { create(options: { body?: { title?: string }; query?: { directory?: string } }): Promise>; prompt(options: { path: { id: string }; query?: { directory?: string }; body: { model: { providerID: string; modelID: string }; agent?: string; system?: string; tools?: Record; parts: Array<{ type: "text"; text: string }>; }; signal?: AbortSignal; }): Promise>; delete(options: { path: { id: string }; query?: { directory?: string } }): Promise>; messages(options: { path: { id: string }; query?: { directory?: string } }): Promise>; }; mcp: { add(options: { query?: { directory?: string }; body: { name: string; config: { type: "remote"; url: string; headers?: Record; oauth?: false } }; }): Promise>; disconnect(options: { path: { name: string }; query?: { directory?: string } }): Promise>; }; }; export type OpencodeHarnessOptions = { /** Use an existing client (tests, or a server you started yourself). */ client?: OpencodeClient; /** Keep opencode sessions after each run instead of deleting them. */ keepSessions?: boolean; /** Extra opencode config merged over the defaults, e.g. a custom `provider`. */ config?: Record; }; export function opencodeHarness(options: OpencodeHarnessOptions = {}): Harness { let started: Promise<{ client: OpencodeClient; close(): void }> | undefined; const connect = () => { if (options.client) return Promise.resolve({ client: options.client, close() {} }); started ??= startServer(options.config); return started; }; return { name: "opencode", async run(request: HarnessRequest): Promise { const { client } = await connect(); const model = splitModel(request.model); const query = request.sandbox.workdir ? { directory: request.sandbox.workdir } : undefined; const toolServer = request.tools.length > 0 ? await attachTools(client, request, query) : undefined; let session: { id: string } | undefined; try { session = unwrap(await client.session.create({ body: { title: "machine run" }, query }), "create a session"); const reply = unwrap( await client.session.prompt({ path: { id: session.id }, query, body: { model, agent: MACHINE_AGENT, system: request.instructions, // Only this run's machine tools; opencode's built-in tools stay off. tools: { "*": false, ...(toolServer ? { [`${toolServer.name}_*`]: true } : {}) }, parts: [{ type: "text", text: withTranscript(request.history, request.input) }], }, signal: request.signal, }), "prompt the model", ); if (reply.info.error) { const { name, data } = reply.info.error; throw new Error(`opencode model error${name ? ` (${name})` : ""}: ${data?.message ?? "unknown error"}`); } // A turn with tool calls spans several assistant messages; the prompt // reply is only the last. Read the session back for the whole turn. const messages = unwrap(await client.session.messages({ path: { id: session.id }, query }), "read the session"); const steps = messages.filter((m) => m.info.role === "assistant"); return toResult(request, steps.length > 0 ? steps : [reply], toolServer?.name); } finally { if (session && !options.keepSessions) await client.session.delete({ path: { id: session.id }, query }).catch(() => {}); await toolServer?.detach(); } }, async close() { if (!started) return; const server = await started.catch(() => undefined); started = undefined; server?.close(); }, }; } /** * Serve the run's tools over MCP from this process and register the server * with opencode under a run-specific name. opencode exposes them as * "_". */ async function attachTools(client: OpencodeClient, request: HarnessRequest, query?: { directory: string }) { const server = await serveToolsOverMcp(request.tools, { sandbox: request.sandbox, signal: request.signal }); const name = `machine${randomBytes(4).toString("hex")}`; try { unwrap( await client.mcp.add({ query, body: { name, config: { type: "remote", url: server.url, headers: { authorization: `Bearer ${server.token}` }, oauth: false } }, }), "register the machine's tools", ); } catch (error) { await server.close(); throw error; } return { name, async detach() { await client.mcp.disconnect({ path: { name }, query }).catch(() => {}); await server.close(); }, }; } function toResult(request: HarnessRequest, steps: OpencodeMessage[], toolPrefix?: string): HarnessResult { const trajectory: Message[] = [{ role: "user", content: request.input }]; const texts: string[] = []; let stopReason = steps.at(-1)?.info.finish; let inputTokens = 0; let outputTokens = 0; for (const step of steps) { inputTokens += step.info.tokens?.input ?? 0; outputTokens += step.info.tokens?.output ?? 0; } for (const part of steps.flatMap((m) => m.parts)) { if (part.type === "text") { const { text, synthetic } = part as { text: string; synthetic?: boolean }; if (synthetic || !text) continue; texts.push(text); trajectory.push({ role: "assistant", content: text }); } else if (part.type === "tool") { const { callID, tool: rawName, state } = part as Extract; const tool = toolPrefix && rawName.startsWith(`${toolPrefix}_`) ? rawName.slice(toolPrefix.length + 1) : rawName; trajectory.push({ role: "assistant", content: "", name: tool, toolCallId: callID, toolInput: state.input }); const output = state.status === "error" ? `error: ${state.error ?? ""}` : (state.output ?? ""); trajectory.push({ role: "tool", content: output, name: tool, toolCallId: callID }); } else if (part.type === "step-finish") { stopReason = (part as { reason?: string }).reason ?? stopReason; } } return { trajectory, output: texts.at(-1) ?? "", model: request.model, usage: { inputTokens, outputTokens }, stopReason, }; } /** "openrouter/thinkingmachines/inkling:free" → provider "openrouter", model "thinkingmachines/inkling:free". */ export function splitModel(model: string): { providerID: string; modelID: string } { const slash = model.indexOf("/"); if (slash <= 0 || slash === model.length - 1) { throw new Error(`opencode models are "provider/model", e.g. "openrouter/thinkingmachines/inkling:free"; got "${model}".`); } return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }; } function unwrap(result: Result, what: string): T { if (result.error !== undefined || result.data === undefined) { throw new Error(`opencode could not ${what}: ${JSON.stringify(result.error ?? "no data").slice(0, 500)}`); } return result.data; } /** * The opencode agent every run uses. Its prompt replaces opencode's default * coding-agent system prompt (about 9k characters about codebases, git, and * tests), so the machine's instructions are the only real instructions the * model sees. opencode still appends a short environment block (model id, * working directory, platform, date) before the machine's instructions. */ export const MACHINE_AGENT = "machine"; const MACHINE_AGENT_PROMPT = "Follow the instructions that come after this, which describe your task."; /** Server config: the machine agent, nothing shared, and every tool off unless a run enables its own. */ export function serverConfig(extra: Record = {}) { return { ...extra, tools: { "*": false }, share: "disabled", autoupdate: false, agent: { [MACHINE_AGENT]: { mode: "primary", prompt: MACHINE_AGENT_PROMPT, tools: { "*": false } } }, }; } /** * Environment for `opencode serve`. A private, empty config home keeps the * user's global opencode config and instruction files (e.g. * ~/.config/opencode/AGENTS.md) out of machine runs, and the flags turn off * project config, Claude Code instruction files and skills, plugins, sharing, * updates, and LSP downloads. Provider keys still come from the environment. */ export function serverEnvironment( configHome: string, extraConfig: Record = {}, base: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { return { ...base, XDG_CONFIG_HOME: configHome, OPENCODE_CONFIG_CONTENT: JSON.stringify(serverConfig(extraConfig)), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_DISABLE_CLAUDE_CODE: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", OPENCODE_PURE: "1", OPENCODE_DISABLE_SHARE: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_LSP_DOWNLOAD: "1", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", }; } async function startServer(extraConfig?: Record): Promise<{ client: OpencodeClient; close(): void }> { let sdk: { createOpencodeClient(config: { baseUrl: string }): unknown }; try { // A variable specifier keeps the SDK optional: it is resolved only at run time. const specifier = "@opencode-ai/sdk"; sdk = await import(specifier); } catch (error) { throw new Error(`The opencode harness needs @opencode-ai/sdk installed (bun add @opencode-ai/sdk): ${error instanceof Error ? error.message : error}`); } const server = await spawnServer(extraConfig); return { client: sdk.createOpencodeClient({ baseUrl: server.url }) as OpencodeClient, close: server.close }; } /** * Start `opencode serve` on a free local port. We spawn it ourselves rather * than through the SDK helper so we hold the process: close() escalates from * SIGTERM to SIGKILL, and the server is killed if this process exits first. */ async function spawnServer(extraConfig?: Record, timeoutMs = 15_000): Promise<{ url: string; close(): void }> { const configHome = await mkdtemp(path.join(os.tmpdir(), "machine-opencode-")); const proc = spawn("opencode", ["serve", "--hostname=127.0.0.1", "--port=0"], { env: serverEnvironment(configHome, extraConfig), stdio: ["ignore", "pipe", "pipe"], }); const removeConfigHome = () => rmSync(configHome, { recursive: true, force: true }); proc.once("exit", removeConfigHome); const kill = () => { if (proc.exitCode !== null || proc.signalCode !== null) return; proc.kill("SIGTERM"); const force = setTimeout(() => proc.kill("SIGKILL"), 2_000); force.unref(); proc.once("exit", () => clearTimeout(force)); }; const onExit = () => { proc.kill("SIGKILL"); removeConfigHome(); }; process.once("exit", onExit); const url = await new Promise((resolve, reject) => { let output = ""; const timer = setTimeout(() => fail(new Error(`opencode server did not start within ${timeoutMs}ms: ${output.slice(-500)}`)), timeoutMs); const fail = (error: Error) => { clearTimeout(timer); kill(); reject(error); }; const read = (chunk: Buffer) => { output += chunk.toString(); const match = output.match(/opencode server listening on (https?:\/\/\S+)/); if (match) { clearTimeout(timer); resolve(match[1]); } }; proc.stdout.on("data", read); proc.stderr.on("data", (chunk: Buffer) => (output += chunk.toString())); proc.once("error", (error) => fail(new Error(`Could not start opencode (is it on PATH?): ${error.message}`))); proc.once("exit", (code) => fail(new Error(`opencode server exited with code ${code}: ${output.slice(-500)}`))); }); return { url, close() { process.off("exit", onExit); kill(); // Remove now: this process may exit before the server's exit event fires. removeConfigHome(); }, }; }