diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 2c12319be..55fa5debd 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -82,17 +82,20 @@ to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK -- `opencode`: OpenCode SDK +- `opencode`: the host-installed OpenCode CLI; DevSpace probes its major version + and uses the matching 1.x or 2.x protocol internally - `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent - `cursor`: ACP - `copilot`: ACP - `grok`: Grok Build ACP (`grok agent stdio`) -Codex is resolved from the user's environment rather than bundled with -DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when -the executable is not on the normal PATH. OpenCode, Cursor, and Copilot -runtimes are started and reused by the daemon internally, while Pi is embedded -through its Node SDK. +Codex and OpenCode are resolved from the user's environment rather than bundled +as executables. Run their normal login/setup flows before using them; set +`CODEX_COMMAND` when Codex is not on the normal PATH. OpenCode is discovered as +`opencode`, and DevSpace automatically routes OpenCode 1.x and 2.x to their +matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are +started and reused by the daemon internally, while Pi is embedded through its +Node SDK. ### `model` diff --git a/docs/configuration.md b/docs/configuration.md index d7b3675f2..271f1b6fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,6 +193,11 @@ environment, then overlays the provider's `env` without mutating the daemon's process environment. OpenCode receives that environment on its managed server process; embedded Pi scopes it to its provider requests and command execution. +The `opencode` driver runs `opencode --version` before creating its first +runtime and automatically selects the OpenCode 1.x or 2.x protocol. Both major +versions use the same provider instance and profile configuration; `opencode2` +is not a separate driver. + Codex, Claude, Cursor, Copilot, and Grok also accept `command`. OpenCode and Pi do not expose a command override. For providers that support it, an explicit `command` wins over both the inherited command override and a command override diff --git a/package.json b/package.json index 57d36c7be..666eb193b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "1.17.13", + "@opencode/client": "2.0.18", "@pierre/diffs": "^1.3.6", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 611ce519d..db036b93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@opencode-ai/sdk': specifier: 1.17.13 version: 1.17.13 + '@opencode/client': + specifier: 2.0.18 + version: 2.0.18(effect@4.0.0-rc.112) '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -717,6 +720,36 @@ packages: resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -729,6 +762,23 @@ packages: '@opencode-ai/sdk@1.17.13': resolution: {integrity: sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==} + '@opencode/client@2.0.18': + resolution: {integrity: sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==} + peerDependencies: + effect: 4.0.0-rc.112 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.18': + resolution: {integrity: sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==} + + '@opencode/schema@2.0.18': + resolution: {integrity: sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1317,6 +1367,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1373,6 +1426,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1737,6 +1794,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1769,6 +1833,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -1869,6 +1937,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -2830,6 +2901,24 @@ snapshots: '@modelcontextprotocol/core': 2.0.0 zod: 4.4.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2843,6 +2932,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.18(effect@4.0.0-rc.112)': + dependencies: + '@opencode/protocol': 2.0.18 + '@opencode/schema': 2.0.18 + optionalDependencies: + effect: 4.0.0-rc.112 + + '@opencode/protocol@2.0.18': + dependencies: + '@opencode/schema': 2.0.18 + effect: 4.0.0-rc.112 + + '@opencode/schema@2.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.112 + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.41.1': {} @@ -3280,6 +3386,11 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3375,6 +3486,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-sha256@1.3.0: {} @@ -3742,6 +3857,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -3765,6 +3896,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 @@ -3874,6 +4010,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b10c94d08..f7842d097 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ allowBuilds: better-sqlite3: true esbuild: true koffi: true + msgpackr-extract: false node-pty: true protobufjs: false diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 86076fbb0..2059cbc9d 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -76,7 +76,7 @@ function createDriver( case "claude": return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); case "opencode": - return new OpencodeLocalAgentDriver(options.opencodeFactory, env); + return new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env }); case "pi": return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); case "cursor": diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index e6916c007..103abd68a 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -50,7 +50,7 @@ function checkLocalAgentProviderAvailability( ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(providerInstanceId, "@opencode-ai/sdk/v2"); + return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "cursor": diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts new file mode 100644 index 000000000..d3de5c440 --- /dev/null +++ b/src/local-agent-opencode-server.ts @@ -0,0 +1,116 @@ +import { createRequire } from "node:module"; +import { createServer as createNetServer } from "node:net"; +import { terminateProcessTree } from "./process-platform.js"; + +const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_START_ATTEMPTS = 3; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export interface OpencodeServerLike { + close(): void; +} + +export interface StartedOpencodeServer extends OpencodeServerLike { + url: string; +} + +export async function startOpencodeServer(env: NodeJS.ProcessEnv): Promise { + for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { + const port = await allocateOpencodePort(); + try { + return await launchOpencodeServer(env, port); + } catch (error) { + if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; + } + } + throw new Error("OpenCode server failed to start."); +} + +async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promise { + const detached = process.platform !== "win32"; + const child = spawn("opencode", [ + "serve", + `--hostname=${OPENCODE_SERVER_HOSTNAME}`, + `--port=${port}`, + ], { + detached, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + terminateProcessTree(child, "SIGTERM", detached); + }; + const url = await new Promise((resolve, reject) => { + let output = ""; + let ready = false; + const timer = setTimeout(() => { + if (ready) return; + close(); + reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); + }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + const inspect = (chunk: Buffer | string) => { + if (ready) return; + output += chunk.toString(); + for (const line of output.split("\n")) { + const match = line.match(/(?:opencode )?server listening on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; + ready = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + }; + child.stdout?.on("data", inspect); + child.stderr?.on("data", inspect); + child.once("error", (error) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(error); + }); + child.once("exit", (code) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); + }); + }); + return { url, close }; +} + +async function allocateOpencodePort(): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an OpenCode server port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + +async function isOpencodePortInUse(port: number): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(true); + else reject(error); + }); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { + server.close((error) => error ? reject(error) : resolve(false)); + }); + }); +} diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts new file mode 100644 index 000000000..636ecce74 --- /dev/null +++ b/src/local-agent-opencode-v2.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { + extractOpenCodeV2FinalResponse, + OpencodeV2Runtime, + opencodeV2Permissions, + opencodeV2ServerEnvironment, + parseOpenCodeV2Model, + type OpencodeV2ClientLike, +} from "./local-agent-opencode-v2.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; +import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +const createInputs: unknown[] = []; +const updateInputs: unknown[] = []; +const switchModelInputs: unknown[] = []; +const promptInputs: unknown[] = []; +const waitInputs: unknown[] = []; +const messageInputs: unknown[] = []; +let closeCalls = 0; + +const assistant: SessionMessageAssistant = { + id: "msg_assistant", + time: { created: 1, completed: 2 }, + type: "assistant", + agent: "build", + model: { id: "sonnet", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "hello" }, + { type: "text", text: " world" }, + ], + finish: "stop", +}; + +const client = { + server: { + async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, + }, + session: { + async create(input: unknown) { + createInputs.push(input); + return { + id: "ses_v2", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async update(input: unknown) { updateInputs.push(input); }, + async get() { + return { + id: "ses_v2", + projectID: "project", + model: { id: "sonnet", providerID: "anthropic" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async switchModel(input: unknown) { switchModelInputs.push(input); }, + async prompt(input: unknown) { + promptInputs.push(input); + return { id: "inbox", sessionID: "ses_v2", time: { created: 1 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + async wait(input: unknown) { waitInputs.push(input); }, + }, + message: { + async list(input: unknown) { + messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; + }, + }, + model: { + async default() { + return { + location: { directory: "/tmp/project" }, + data: { + id: "default-model", + modelID: "default-model", + providerID: "openrouter", + name: "Default", + capabilities: {}, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1, output: 1 }, + }, + }; + }, + }, +} as unknown as OpencodeV2ClientLike; + +const runtime = new OpencodeV2Runtime(client, { + close: () => { closeCalls += 1; }, +}); +let sessionId: string | undefined; +const first = await runtime.run({ + prompt: "first", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + model: "anthropic/sonnet", + effort: "high", +}, { + onSessionId: (id) => { sessionId = id; }, +}); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.equal(sessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.providerSessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.finalResponse, "hello world"); +assert.deepEqual(createInputs[0], { + location: { directory: "/tmp/project" }, + permissions: opencodeV2Permissions("read_only"), + model: { providerID: "anthropic", id: "sonnet", variant: "high" }, +}); +assert.equal(updateInputs.length, 0, "new sessions are configured at creation"); +assert.equal(switchModelInputs.length, 0, "new sessions select their model at creation"); +assert.deepEqual(promptInputs[0], { sessionID: "ses_v2", text: "first" }); +assert.deepEqual(waitInputs[0], { sessionID: "ses_v2" }); +assert.deepEqual(messageInputs[0], { sessionID: "ses_v2", limit: 1, order: "desc", type: "assistant" }); + +const continued = await runtime.run({ + prompt: "continued", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", + writeMode: "allowed", + effort: "low", +}); +assert.equal(continued.isOk(), true); +if (continued.isErr()) throw continued.error; +assert.deepEqual(updateInputs[0], { + sessionID: "ses_v2", + permissions: opencodeV2Permissions("allowed"), +}); +assert.deepEqual(switchModelInputs[0], { + sessionID: "ses_v2", + model: { id: "sonnet", providerID: "anthropic", variant: "low" }, +}); + +const wrongGeneration = await runtime.run({ + prompt: "legacy continuation", + workspaceRoot: "/tmp/project", + providerSessionId: "ses_v1_legacy", +}); +assert.equal(wrongGeneration.isErr(), true); +if (wrongGeneration.isErr()) { + assert.equal(wrongGeneration.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(wrongGeneration.error.message, /different OpenCode protocol generation/); +} + +assert.deepEqual(parseOpenCodeV2Model("anthropic/sonnet", "high"), { + providerID: "anthropic", + id: "sonnet", + variant: "high", +}); +assert.deepEqual(parseOpenCodeV2Model("custom-model"), { + providerID: "opencode", + id: "custom-model", +}); +assert.equal(extractOpenCodeV2FinalResponse(assistant), "hello world"); + +assert.deepEqual(opencodeV2Permissions("full_access"), [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2Permissions("read_only").slice(-3), [ + { action: "external_directory", resource: "*", effect: "deny" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2ServerEnvironment({ + PATH: "/bin", + OPENCODE_PASSWORD: "ambient-v2", + OPENCODE_SERVER_PASSWORD: "ambient-v1", +}, "generated"), { + PATH: "/bin", + OPENCODE_PASSWORD: "generated", +}); + +await runtime.close(); +await runtime.close(); +assert.equal(closeCalls, 1); + +let v1FactoryCalls = 0; +let v2FactoryCalls = 0; +const routedDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + v1FactoryCalls += 1; + throw new Error("v1 factory must not be selected for OpenCode 2"); + }, + v2Factory: async () => { + v2FactoryCalls += 1; + return { client, server: { close: () => undefined } }; + }, + runtimeProbe: new OpenCodeRuntimeProbe(async () => ({ generation: "v2", version: "2.0.20" })), +}); +const routedPool = new LocalAgentRuntimePool(); +const routed = await routedPool.run(routedDriver, { + agentId: "agt_v2", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +}, { + prompt: "route through v2", + workspaceRoot: "/tmp/project", +}); +assert.equal(routed.isOk(), true); +assert.equal(v1FactoryCalls, 0); +assert.equal(v2FactoryCalls, 1); +await routedPool.close(); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts new file mode 100644 index 000000000..5c0fc1ff3 --- /dev/null +++ b/src/local-agent-opencode-v2.ts @@ -0,0 +1,342 @@ +import { randomBytes } from "node:crypto"; +import { + OpenCode, + type ModelRef, + type OpenCodeClient, + type PermissionRule, + type SessionMessageAssistant, +} from "@opencode/client"; +import { + AgentProviderExecutionError, + AgentProviderProtocolError, + AgentProviderUnavailableError, + captureAgentProviderResult, +} from "./local-agent-errors.js"; +import type { + LocalAgentRunCallbacks, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + encodeOpenCodeV2SessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; + +export type OpencodeV2ClientLike = Pick; + +export type OpencodeV2Factory = ( + context?: LocalAgentRuntimeContext, + env?: NodeJS.ProcessEnv, +) => Promise<{ + client: OpencodeV2ClientLike; + server: OpencodeServerLike; +}>; + +export class OpencodeV2Runtime implements LocalAgentRuntime { + readonly provider = "opencode" as const; + private alive = true; + private closed = false; + private readonly promptControllers = new Set(); + + constructor( + private readonly client: OpencodeV2ClientLike, + private readonly server: OpencodeServerLike, + private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, + ) {} + + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "run", + run: async (): Promise => { + if (!this.alive) { + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + message: "OpenCode runtime is not running.", + }); + } + try { + await this.client.server.info(); + const continuing = input.providerSessionId !== undefined; + const nativeSessionId = input.providerSessionId + ? requireOpenCodeV2NativeSessionId(input.providerSessionId) + : await this.createSession(input); + const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); + await callbacks?.onSessionId?.(providerSessionId); + if (continuing) await this.configureSession(nativeSessionId, input); + const messages = await this.prompt(nativeSessionId, input); + const assistant = requireLatestAssistant(messages.data); + assertOpenCodeV2AssistantSucceeded(assistant); + return { + provider: this.provider, + providerSessionId, + finalResponse: requireOpenCodeV2FinalResponse(assistant), + items: messages.data, + }; + } catch (error) { + if (isOpenCodeTransportFailure(error)) { + this.alive = false; + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + cause: error, + message: "OpenCode provider is unavailable.", + }); + } + throw error; + } + }, + }); + } + + async releaseSession(_providerSessionId: string): Promise { + // OpenCode keeps durable sessions independently of this process. + } + + isAlive(): boolean { + return this.alive && !this.closed; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.alive = false; + for (const controller of this.promptControllers) controller.abort(); + this.promptControllers.clear(); + this.server.close(); + } + + private async createSession(input: LocalAgentRunInput): Promise { + const model = await resolveOpenCodeV2Model(this.client, input); + const session = await this.client.session.create({ + location: { directory: input.workspaceRoot }, + permissions: opencodeV2Permissions(input.writeMode), + ...(model ? { model } : {}), + }); + if (!session.id) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "create_session", + retryable: false, + message: "OpenCode did not return a session id.", + }); + } + return session.id; + } + + private async configureSession(sessionId: string, input: LocalAgentRunInput): Promise { + await this.client.session.update({ + sessionID: sessionId, + permissions: opencodeV2Permissions(input.writeMode), + }); + if (!input.model && !input.effort) return; + const current = await this.client.session.get({ sessionID: sessionId }); + const model = await resolveOpenCodeV2Model(this.client, input, current.model); + if (!model) return; + await this.client.session.switchModel({ sessionID: sessionId, model }); + } + + private async prompt(sessionId: string, input: LocalAgentRunInput) { + const controller = new AbortController(); + this.promptControllers.add(controller); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.promptTimeoutMs); + try { + await this.client.session.prompt({ + sessionID: sessionId, + text: input.prompt, + }, { signal: controller.signal }); + await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); + return await this.client.message.list({ + sessionID: sessionId, + limit: 1, + order: "desc", + type: "assistant", + }, { signal: controller.signal }); + } catch (error) { + if (!timedOut) throw error; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "prompt", + retryable: true, + cause: error, + message: "OpenCode did not finish the prompt before the provider timeout.", + }); + } finally { + clearTimeout(timer); + this.promptControllers.delete(controller); + } + } +} + +export async function defaultOpencodeV2Factory( + _context?: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ client: OpencodeV2ClientLike; server: OpencodeServerLike }> { + const password = randomBytes(32).toString("base64url"); + const server = await startOpencodeServer(opencodeV2ServerEnvironment(env, password)); + const authorization = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + return { + client: OpenCode.make({ + baseUrl: server.url, + headers: { authorization }, + }), + server, + }; +} + +export function opencodeV2ServerEnvironment( + env: NodeJS.ProcessEnv, + password: string, +): NodeJS.ProcessEnv { + const { + OPENCODE_PASSWORD: _opencodePassword, + OPENCODE_SERVER_PASSWORD: _legacyPassword, + ...rest + } = env; + return { ...rest, OPENCODE_PASSWORD: password }; +} + +export function opencodeV2Permissions( + writeMode: LocalAgentRunInput["writeMode"], +): PermissionRule[] { + const rules: PermissionRule[] = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, + ]; + if (writeMode !== "full_access") { + rules.push({ action: "external_directory", resource: "*", effect: "deny" }); + } + if (writeMode === "read_only") { + rules.push( + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, + ); + } + return rules; +} + +async function resolveOpenCodeV2Model( + client: OpencodeV2ClientLike, + input: LocalAgentRunInput, + current?: ModelRef, +): Promise { + if (input.model) return parseOpenCodeV2Model(input.model, input.effort); + if (!input.effort) return undefined; + if (current) return { ...current, variant: input.effort }; + const fallback = await client.model.default({ + location: { directory: input.workspaceRoot }, + }); + if (!fallback.data) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "select_model", + retryable: false, + message: "OpenCode has no default model to apply the requested effort variant to.", + }); + } + return { + id: fallback.data.id, + providerID: fallback.data.providerID, + variant: input.effort, + }; +} + +export function parseOpenCodeV2Model(model: string, effort?: string): ModelRef { + const separator = model.indexOf("/"); + return { + providerID: separator === -1 ? "opencode" : model.slice(0, separator), + id: separator === -1 ? model : model.slice(separator + 1), + ...(effort ? { variant: effort } : {}), + }; +} + +export function extractOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + return message.content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); +} + +function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + const response = extractOpenCodeV2FinalResponse(message); + if (!response) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return a final assistant response.", + }); + } + return response; +} + +function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { + const assistant = messages.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "assistant" + )); + if (!assistant) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return an assistant message after the prompt completed.", + }); + } + return assistant as SessionMessageAssistant; +} + +function assertOpenCodeV2AssistantSucceeded(message: SessionMessageAssistant): void { + if (!message.error) return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: message.error.status === 429 || (message.error.status ?? 0) >= 500, + cause: message.error, + message: message.error.message || `OpenCode returned ${message.error.type}.`, + }); +} + +function isOpenCodeTransportFailure(error: unknown): boolean { + const code = transportErrorCode(error); + return code === "ECONNREFUSED" + || code === "ECONNRESET" + || code === "EPIPE" + || code === "ENETDOWN" + || code === "ENETUNREACH" + || code === "ETIMEDOUT"; +} + +function transportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; + const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + ? (cause as NodeJS.ErrnoException).code + : undefined; +} diff --git a/src/local-agent-opencode-version.test.ts b/src/local-agent-opencode-version.test.ts new file mode 100644 index 000000000..b8cf06035 --- /dev/null +++ b/src/local-agent-opencode-version.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { + classifyOpenCodeCliVersion, + encodeOpenCodeV2SessionId, + OpenCodeRuntimeProbe, + requireOpenCodeV1NativeSessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +assert.deepEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.0.20\n"), { + generation: "v2", + version: "2.0.20", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.1.0-beta.2\n"), { + generation: "v2", + version: "2.1.0-beta.2", +}); +assert.equal(classifyOpenCodeCliVersion("opencode unknown"), undefined); + +assert.equal(encodeOpenCodeV2SessionId("ses_v2"), "opencode:v2:ses_v2"); +assert.equal(requireOpenCodeV2NativeSessionId("opencode:v2:ses_v2"), "ses_v2"); +assert.equal(requireOpenCodeV1NativeSessionId("ses_v1"), "ses_v1"); +assert.throws( + () => requireOpenCodeV2NativeSessionId("ses_v1"), + /different OpenCode protocol generation/, +); +assert.throws( + () => requireOpenCodeV1NativeSessionId("opencode:v2:ses_v2"), + /different OpenCode protocol generation/, +); + +let successfulCalls = 0; +const cached = new OpenCodeRuntimeProbe(async () => { + successfulCalls += 1; + return { generation: "v2", version: "2.0.20" }; +}); +assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); +assert.deepEqual(await cached.get(), { generation: "v2", version: "2.0.20" }); +assert.equal(successfulCalls, 1, "successful probes are cached"); + +let attempts = 0; +const retriesFailures = new OpenCodeRuntimeProbe(async () => { + attempts += 1; + if (attempts === 1) throw new Error("not ready"); + return { generation: "v1", version: "1.18.32" }; +}); +await assert.rejects(() => retriesFailures.get(), /not ready/); +assert.deepEqual(await retriesFailures.get(), { generation: "v1", version: "1.18.32" }); +assert.equal(attempts, 2, "failed probes are not cached"); diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts new file mode 100644 index 000000000..62150e7b5 --- /dev/null +++ b/src/local-agent-opencode-version.ts @@ -0,0 +1,155 @@ +import { createRequire } from "node:module"; +import { + AgentProviderProtocolError, + AgentProviderUnavailableError, +} from "./local-agent-errors.js"; + +const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 4_000; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export type OpenCodeGeneration = "v1" | "v2"; + +export interface ProbedOpenCodeRuntime { + generation: OpenCodeGeneration; + version: string; +} + +export type OpenCodeRuntimeProbeFunction = () => Promise; + +const OPENCODE_V2_SESSION_PREFIX = "opencode:v2:"; + +export class OpenCodeRuntimeProbe { + private lastSuccessfulProbe?: ProbedOpenCodeRuntime; + + constructor(private readonly probe: OpenCodeRuntimeProbeFunction) {} + + async get(): Promise { + if (this.lastSuccessfulProbe) return this.lastSuccessfulProbe; + return this.refresh(); + } + + async refresh(): Promise { + const result = await this.probe(); + this.lastSuccessfulProbe = result; + return result; + } + + lastSuccess(): ProbedOpenCodeRuntime | undefined { + return this.lastSuccessfulProbe; + } +} + +export function createOpenCodeRuntimeProbe( + env: NodeJS.ProcessEnv = process.env, +): OpenCodeRuntimeProbe { + return new OpenCodeRuntimeProbe(() => probeOpenCodeBinary(env)); +} + +export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { + const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); + if (!match?.[1] || !match[2]) return undefined; + const major = Number(match[2]); + const version = match[1]; + return { + generation: major >= 2 ? "v2" : "v1", + version, + }; +} + +export function encodeOpenCodeV2SessionId(nativeSessionId: string): string { + return `${OPENCODE_V2_SESSION_PREFIX}${nativeSessionId}`; +} + +export function requireOpenCodeV2NativeSessionId(providerSessionId: string): string { + if (providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) { + const nativeSessionId = providerSessionId.slice(OPENCODE_V2_SESSION_PREFIX.length); + if (nativeSessionId) return nativeSessionId; + } + throw incompatibleOpenCodeSession("v2", providerSessionId); +} + +export function requireOpenCodeV1NativeSessionId(providerSessionId: string): string { + if (!providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) return providerSessionId; + throw incompatibleOpenCodeSession("v1", providerSessionId); +} + +function incompatibleOpenCodeSession( + generation: OpenCodeGeneration, + providerSessionId: string, +): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "resume_session", + retryable: false, + message: `OpenCode ${generation} cannot resume session ${providerSessionId}; the session belongs to a different OpenCode protocol generation. Start a new agent after changing OpenCode major versions.`, + }); +} + +export async function probeOpenCodeBinary( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const output = await runOpenCodeVersion(env); + const result = classifyOpenCodeCliVersion(output); + if (result) return result; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "probe_version", + retryable: false, + message: "Unable to determine the installed OpenCode version from `opencode --version`.", + }); +} + +function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn("opencode", ["--version"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT_MS}ms.`, + })); + }, OPENCODE_VERSION_PROBE_TIMEOUT_MS); + timer.unref(); + child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.once("error", (cause) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + cause, + message: "OpenCode executable is unavailable.", + })); + }); + child.once("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe exited with code ${code}${output.trim() ? `: ${output.trim()}` : "."}`, + })); + }); + }); +} diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 6b12a1f76..9bc2d271d 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -11,8 +11,13 @@ import { type OpencodeClientLike, type OpencodeFactory, } from "./local-agent-opencode.js"; +import { OpenCodeRuntimeProbe } from "./local-agent-opencode-version.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +function v1Probe(): OpenCodeRuntimeProbe { + return new OpenCodeRuntimeProbe(async () => ({ generation: "v1", version: "1.17.13" })); +} + let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; @@ -53,7 +58,11 @@ const factory: OpencodeFactory = async (_context, env) => { server: { close: () => { closeCalls += 1; } }, }; }; -const driver = new OpencodeLocalAgentDriver(factory, { HARNESS_ENV: "opencode" }); +const driver = new OpencodeLocalAgentDriver({ + factory, + env: { HARNESS_ENV: "opencode" }, + runtimeProbe: v1Probe(), +}); const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { @@ -131,16 +140,19 @@ if (process.platform !== "win32") { "", ].join("\n")); await chmod(command, 0o700); - const envDriver = new OpencodeLocalAgentDriver(undefined, { - PATH: commandRoot, - HARNESS_ENV: "opencode-child", - MARKER: marker, - ARGS_MARKER: argsMarker, - COLLISION_MARKER: collisionMarker, - HOLDER_READY: holderReady, - HOLDER_PROCESS: holderProcess, - HOLDER_LAUNCHER: holderLauncher, - NODE_EXECUTABLE: process.execPath, + const envDriver = new OpencodeLocalAgentDriver({ + env: { + PATH: commandRoot, + HARNESS_ENV: "opencode-child", + MARKER: marker, + ARGS_MARKER: argsMarker, + COLLISION_MARKER: collisionMarker, + HOLDER_READY: holderReady, + HOLDER_PROCESS: holderProcess, + HOLDER_LAUNCHER: holderLauncher, + NODE_EXECUTABLE: process.execPath, + }, + runtimeProbe: v1Probe(), }); const created = await envDriver.createRuntime({ agentId: "agt_env", @@ -281,10 +293,13 @@ const applicationErrorClient = { }, } as unknown as OpencodeClientLike; const applicationErrorPool = new LocalAgentRuntimePool(); -const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ - client: applicationErrorClient, - server: { close: () => undefined }, -})); +const applicationErrorDriver = new OpencodeLocalAgentDriver({ + factory: async () => ({ + client: applicationErrorClient, + server: { close: () => undefined }, + }), + runtimeProbe: v1Probe(), +}); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", providerInstanceId: "opencode", @@ -310,10 +325,13 @@ assert.equal(recoveredApplicationTurn.value.finalResponse, "ok"); await applicationErrorPool.close(); let recoveringFactoryCalls = 0; -const recoveringDriver = new OpencodeLocalAgentDriver(async () => { - recoveringFactoryCalls += 1; - healthAvailable = true; - return { client, server: { close: () => undefined } }; +const recoveringDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + recoveringFactoryCalls += 1; + healthAvailable = true; + return { client, server: { close: () => undefined } }; + }, + runtimeProbe: v1Probe(), }); const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 3a7a3eba2..5f3f5fdd1 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,5 +1,3 @@ -import { createRequire } from "node:module"; -import { createServer as createNetServer } from "node:net"; import type { OpencodeClient, PermissionConfig, @@ -18,14 +16,19 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { terminateProcessTree } from "./process-platform.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + defaultOpencodeV2Factory, + OpencodeV2Runtime, + type OpencodeV2Factory, +} from "./local-agent-opencode-v2.js"; +import { + createOpenCodeRuntimeProbe, + requireOpenCodeV1NativeSessionId, + type OpenCodeRuntimeProbe, +} from "./local-agent-opencode-version.js"; -const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; -const OPENCODE_SERVER_START_ATTEMPTS = 3; const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; -const require = createRequire(import.meta.url); -const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; interface OpencodeModelRef { providerID: string; @@ -34,10 +37,6 @@ interface OpencodeModelRef { export type OpencodeClientLike = Pick; -export interface OpencodeServerLike { - close(): void; -} - export type OpencodeFactory = ( context?: LocalAgentRuntimeContext, env?: NodeJS.ProcessEnv, @@ -74,7 +73,9 @@ export class OpencodeRuntime implements LocalAgentRuntime { } try { await assertOpencodeHealthy(this.client); - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); + const sessionId = input.providerSessionId + ? requireOpenCodeV1NativeSessionId(input.providerSessionId) + : await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); @@ -151,11 +152,22 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly providerInstanceId = "opencode"; readonly idleTimeoutMs = 5 * 60_000; - - constructor( - private readonly factory: OpencodeFactory = defaultOpencodeFactory, - private readonly env: NodeJS.ProcessEnv = process.env, - ) {} + private readonly factory: OpencodeFactory; + private readonly v2Factory: OpencodeV2Factory; + private readonly env: NodeJS.ProcessEnv; + private readonly runtimeProbe: OpenCodeRuntimeProbe; + + constructor(options: { + factory?: OpencodeFactory; + v2Factory?: OpencodeV2Factory; + env?: NodeJS.ProcessEnv; + runtimeProbe?: OpenCodeRuntimeProbe; + } = {}) { + this.factory = options.factory ?? defaultOpencodeFactory; + this.v2Factory = options.v2Factory ?? defaultOpencodeV2Factory; + this.env = options.env ?? process.env; + this.runtimeProbe = options.runtimeProbe ?? createOpenCodeRuntimeProbe(this.env); + } runtimeKey(_context: LocalAgentRuntimeContext): string { return "opencode:default"; @@ -167,6 +179,11 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { + const runtime = await this.runtimeProbe.get(); + if (runtime.generation === "v2") { + const { client, server } = await this.v2Factory(context, this.env); + return new OpencodeV2Runtime(client, server); + } const { client, server } = await this.factory(context, this.env); return new OpencodeRuntime(client, server); }, @@ -186,124 +203,16 @@ async function defaultOpencodeFactory( devspace_full_access: opencodeAgentConfig("full_access"), }, }; - const server = await startOpencodeServer(env, config); + const server = await startOpencodeServer({ + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }); return { client: createOpencodeClient({ baseUrl: server.url }), server, }; } -async function startOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, -): Promise { - for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { - const port = await allocateOpencodePort(); - try { - return await launchOpencodeServer(env, config, port); - } catch (error) { - if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; - } - } - throw new Error("OpenCode server failed to start."); -} - -async function launchOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, - port: number, -): Promise { - const detached = process.platform !== "win32"; - const child = spawn("opencode", [ - "serve", - `--hostname=${OPENCODE_SERVER_HOSTNAME}`, - `--port=${port}`, - ], { - detached, - env: { - ...env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(config), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - terminateProcessTree(child, "SIGTERM", detached); - }; - const url = await new Promise((resolve, reject) => { - let output = ""; - let ready = false; - const timer = setTimeout(() => { - if (ready) return; - close(); - reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); - }, OPENCODE_SERVER_START_TIMEOUT_MS); - timer.unref(); - child.stdout?.on("data", (chunk: Buffer | string) => { - if (ready) return; - output += chunk.toString(); - for (const line of output.split("\n")) { - if (!line.startsWith("opencode server listening")) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match?.[1]) continue; - ready = true; - clearTimeout(timer); - resolve(match[1]); - return; - } - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - if (!ready) output += chunk.toString(); - }); - child.once("error", (error) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(error); - }); - child.once("exit", (code) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); - }); - }); - return { url, close }; -} - -async function allocateOpencodePort(): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to allocate an OpenCode server port.")); - return; - } - server.close((error) => error ? reject(error) : resolve(address.port)); - }); - }); -} - -async function isOpencodePortInUse(port: number): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") resolve(true); - else reject(error); - }); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { - server.close((error) => error ? reject(error) : resolve(false)); - }); - }); -} - export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig;