UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

1,003 lines (1,002 loc) • 42.9 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * OpenCode SDK agent runner (migrated from `agent/sdk-runner.ts`, #564). * * Uses the embedded `@opencode-ai/sdk` instead of `Bun.spawn` for dispatch, so * no agent CLI argv is ever constructed — but the SDK is not self-contained: * {@link createManagedOpencode} spawns `opencode serve` and talks HTTP to it, * so the `opencode` binary must be on PATH (which is why setup's * `detectHarness` gates this harness on that probe). The user provides an * OpenAI-compatible endpoint (or inherits from the selected fallback LLM * engine) for the SDK. * * This is the runtime surface of the {@link OpencodeSdkHarness} (`id = * 'opencode-sdk'`). It is the dispatch path for SDK runner specs; it exposes * no native session logs of its own (`capabilities.sessionLogs = false`). * * ## Per-call cwd and env (redesign addendum R2, open seam decision 1) * * The plan left one decision open: per-call cwd/env forwarding vs a server * keyed by `(cwd, envKeysHash)`. Reading the SDK settled it as a SPLIT — the * two halves have different API realities (verified against * `@opencode-ai/sdk` 1.2.20): * * - **cwd is PER-CALL.** `session.create` / `session.prompt` / * `session.delete` all accept a `query.directory` parameter that scopes * the session's working directory, so a single server can host sessions * in any number of working directories. {@link RunAgentOptions.cwd} is * forwarded as `query: { directory }` on every session call — no * per-cwd server processes, no server-key explosion for worktree * isolation (which mints a fresh directory per unit attempt). * * - **env is PER-SERVER (keyed registry).** The SDK exposes NO per-call or * per-session env surface; the only way env reaches tool child processes * is the `opencode serve` process environment. {@link getOrStartServer} * snapshots and materializes the complete child environment for each * dispatch, passes it directly to the managed spawn (never mutating * `process.env`), and hashes that exact canonical environment for registry * identity. Identical child material shares one server; any material * difference receives a separate server. * * This is what removed the workflow engine's `env_unsupported` hard-fail for * the sdk runner: injection genuinely reaches the child, because tool * subprocesses (bash etc.) inherit the server process environment. * * Registry hygiene (peer-review fixes): * * - **Ports.** `createOpencodeServer` binds a FIXED default port (4096), * so coexisting registry entries would contend for the same bind. Every * entry is started on its own reserved OS-assigned port (see * {@link startServer}). * - **Shutdown.** {@link closeServer} closes resolved servers * SYNCHRONOUSLY — it runs from `process.once('exit')`, where Bun never * drains microtasks, so a `.then()`-based close would orphan every * `opencode serve` child. * * Process-lifecycle note (owner finding 4): a cached `opencode serve` child is * a live OS handle that keeps Bun's event loop OPEN, so a one-shot CLI never * becomes idle and `process.once('exit')` never fires — the exit hook alone * cannot free a process the child is keeping alive (a deadlock that hangs the * caller after an otherwise-successful run). The registry is therefore drained * PROACTIVELY at the end of a dispatching command: the workflow engine calls * `disposeDispatchResources()` (→ {@link closeServer}) in composition-root and * workflow `finally` blocks. * The `process.once('exit')` hook stays as the last-resort backstop for paths * that never reach that drain. * * ## Managed server spawn (owner finding 4, live-harness follow-up) * * Draining the registry is necessary but NOT sufficient with the SDK's own * `createOpencodeServer`: its `close()` merely sends SIGTERM and it never * `unref()`s the child or its stdio pipes, so akm's event loop stays pinned * until the child ACTUALLY exits — and a real `opencode serve` (a live HTTP * server with provider children) can outlive SIGTERM long enough to hang the * caller indefinitely. {@link createManagedOpencode} therefore owns the spawn * (the SDK package is used only for `createOpencodeClient`). * * Owning the spawn changes the LIFECYCLE, not the REQUIREMENT: the SDK's * `createOpencodeServer` is itself a `spawn("opencode", ["serve", ...])`, and * the package declares `"dependencies": {}`, so `opencode` must be on PATH * either way. akm's version differs only in how it supervises the child: * * - after the URL handshake, the child and its stdio are `unref()`ed / * destroyed, so the handle can never hold akm open; * - `close()` sends SIGTERM and arms a bounded grace timer * ({@link SERVER_KILL_GRACE_MS}) that escalates to SIGKILL and is cleared * on cooperative exit, so stubborn children cannot survive parent exit and * stale timers cannot signal a reused PID; * - the spawn receives the immutable per-dispatch environment directly, so * asynchronous factory setup cannot race a temporary process-wide overlay. */ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { isRecord } from "../../../core/common.js"; import { COMMON_SPAWN_ENV_PASSTHROUGH, spawnEnvNamesFor, XDG_BASE_DIR_ENV_PASSTHROUGH } from "../../../core/spawn-env.js"; import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js"; import { opencodeInferenceConfig } from "../opencode/model-config.js"; import { MODEL_WORK_OPENCODE_AGENT, modelWorkOpencodeConfig, modelWorkPluginEnv } from "../opencode/model-work-agent.js"; // Server registry — one server per complete server-material signature. Caller // deadlines race the shared promise independently; they never become startup // configuration inherited by later callers. const _servers = new Map(); // Resolved servers by registry key, mirrored from `_servers` as each start // promise settles. This exists so closeServer() can close started servers // SYNCHRONOUSLY: it is wired to `process.once('exit')`, and Bun does not // drain microtasks scheduled inside 'exit' handlers, so a `.then()`-based // close never runs there and would orphan every `opencode serve` child. const _resolvedServers = new Map(); // Listen ports handed to non-default registry entries (see startServer) — // tracked so two coexisting servers in this process can never be assigned // the same port. const _serverPorts = new Map(); // Serializes probe-and-reserve so concurrent starts cannot claim one port. let _portAllocationTail = Promise.resolve(); /** The port `createOpencodeServer` binds when none is passed (SDK 1.2.20). */ const DEFAULT_SDK_PORT = 4096; // Test override: when set, every call uses this server (all keys) and no real // server is ever started. let _testServer = null; // Test seam replacing the real `createOpencode` import (see __setServerFactory). let _serverFactory = null; let _exitHookInstalled = false; /** * True once `process.on("exit")` has fired. Close paths consult it because an * exit handler runs after the event loop has stopped: no timer scheduled there * will ever fire, so any cleanup that needs to happen must happen inline. */ let _processExiting = false; /** * Test-only seam: inject a fake {@link SdkServer} so `runOpencodeSdk` can be * exercised without the real `@opencode-ai/sdk` (which would spin up a server). * Pass `null` to clear. NOT part of the public runtime API — used only to * assert the #564 bug fixes (systemPrompt/tools forwarding + timeout). The * leading underscores mark it as internal. */ export function __setTestServer(server) { _testServer = server; } /** * Test-only seam: replace the `createOpencode` factory so the env-keyed * server registry (module doc, *Per-call cwd and env*) can be exercised * without the real SDK. The complete child environment is supplied as * `options.env`, so async factories observe the same immutable values as the * production spawn. Pass `null` to clear. */ export function __setServerFactory(factory) { _serverFactory = factory; } /** * Close every started OpenCode SDK server and reset the registry (and any * injected test server). Used by tests for clean teardown between runs and * wired to `process.once('exit')` — which is why resolved servers MUST be * closed synchronously here: Bun never drains microtasks scheduled inside * 'exit' handlers, so a promise-based close would silently orphan the * `opencode serve` children (leaking processes AND keeping their ports * bound for the next invocation). */ export async function closeServer() { const closes = []; for (const [key, entry] of _servers) { const resolved = _resolvedServers.get(key); if (resolved) { // Synchronous close — safe from the 'exit' hook. try { closes.push(Promise.resolve(resolved.server.close())); } catch { /* ignore */ } } else { // Still starting: cancel the real managed spawn immediately. A custom // factory may ignore the signal; the registry settlement handler closes // any late result without awaiting it and pinning shutdown indefinitely. entry.controller.abort(); } } _servers.clear(); _resolvedServers.clear(); _serverPorts.clear(); try { if (_testServer) closes.push(Promise.resolve(_testServer.server.close())); } catch { /* ignore */ } _testServer = null; await Promise.allSettled(closes); } /** * Convert an `AgentDispatchRequest.tools` policy into the SDK's tool-allowlist * shape (`{ [toolName]: boolean }`). * * #564 bug fix (2): the tool list was previously dropped entirely. The CLI * builder passes tools as a comma-separated `--allowedTools` flag; the SDK * instead wants a per-tool boolean map. A list/comma-string of tool names is * treated as an allowlist (each name → true). A structured policy object whose * values are already booleans is forwarded as-is. * * Returns `undefined` when there is nothing to forward, so an absent policy * leaves the SDK's own defaults untouched (behaviour-preserving for callers * that pass no tools). */ function toolsToSdkAllowlist(tools) { if (tools === undefined || tools === null) return undefined; const names = []; if (typeof tools === "string") { names.push(...tools .split(",") .map((t) => t.trim()) .filter(Boolean)); } else if (Array.isArray(tools)) { for (const t of tools) { if (typeof t === "string" && t.trim()) names.push(t.trim()); } } else if (typeof tools === "object") { // Structured policy: forward boolean entries directly. const out = {}; for (const [k, v] of Object.entries(tools)) { if (typeof v === "boolean") out[k] = v; } return Object.keys(out).length > 0 ? out : undefined; } if (names.length === 0) return undefined; const out = {}; for (const n of names) out[n] = true; return out; } /** The inference a fallback LLM connection carries, which opencode can take. */ function fallbackInference(llmConfig) { const out = {}; for (const key of ["temperature", "maxTokens", "contextLength", "enableThinking", "reasoningEffort"]) { if (llmConfig?.[key] !== undefined) out[key] = llmConfig[key]; } return out; } /** * Assemble the OpenCode SDK server config from the profile + LLM fallback. * Pure and exported for tests. `profile.model` is already exact because model * aliases resolve once before harness lowering. A server for model work also * defines the confined model-work agent (`../opencode/model-work-agent`). * * The model the config routes through `akm-custom` is declared in full, with * the dispatch's inference (`../opencode/model-config`): the fallback LLM * engine's, under `requestInference`, the request's own. For model work the * options go on the confined agent instead of the model, which needs no model * named: it runs whichever model opencode picks. A model the user's own opencode * config provides carries none: set inference there. */ export function buildSdkConfig(profile, llmConfig, modelWork = false, requestInference) { const endpoint = llmConfig?.endpoint; const apiKey = llmConfig?.apiKey; const profileModel = profile.model; const model = profileModel ?? llmConfig?.model; const inference = requestInference === null ? undefined : { ...fallbackInference(llmConfig), ...requestInference }; const { entry, agentOptions } = opencodeInferenceConfig(inference, modelWork); const sdkConfig = {}; if (model) sdkConfig.model = model; if (endpoint || apiKey) { // The first path segment selects the OpenCode provider. Model IDs may // themselves contain slashes, but still belong to this custom endpoint. const modelId = model?.startsWith("akm-custom/") ? model.slice("akm-custom/".length) : model; // Configure a custom OpenAI-compatible provider. OpenCode registers only // the models a custom provider lists, so the routed model is declared // (#1015: without it every dispatch failed with ProviderModelNotFoundError). sdkConfig.provider = { "akm-custom": { npm: "@ai-sdk/openai-compatible", options: { baseURL: canonicalProviderBase(endpoint) ?? undefined, ...(apiKey ? { apiKey } : {}), }, ...(modelId ? { models: { [modelId]: entry } } : {}), }, }; if (modelId) sdkConfig.model = `akm-custom/${modelId}`; } return modelWork ? { ...sdkConfig, ...modelWorkOpencodeConfig(agentOptions) } : sdkConfig; } /** Digest the executable and exact environment received by the child. */ function serverRegistryKey(profile, env) { const material = { bin: profile.bin, env }; return createHash("sha256") .update(JSON.stringify(canonicalize(material))) .digest("hex"); } /** * @internal Exact environment allowlist used to start the OpenCode SDK server: * the common baseline, the XDG base-directory variables opencode resolves its * config, data, cache and state from, and the profile's own names. The XDG names * are the server's, not the profile's: profile `envPassthrough` is frozen into * workflow plans, and an SDK profile's list has always been empty. */ export function opencodeSdkServerEnvironmentNames(profile) { return [ ...new Set([ ...spawnEnvNamesFor(COMMON_SPAWN_ENV_PASSTHROUGH), ...XDG_BASE_DIR_ENV_PASSTHROUGH, ...(profile.envPassthrough ?? []), ]), ]; } function buildServerEnv(profile, config, bindings, envSource, modelWork) { const env = {}; for (const key of opencodeSdkServerEnvironmentNames(profile)) { const value = envSource[key]; if (value !== undefined) env[key] = value; } for (const [key, value] of Object.entries(bindings ?? {})) env[key] = value; if (modelWork) Object.assign(env, modelWorkPluginEnv()); env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config); return env; } function canonicalProviderBase(endpoint) { if (!endpoint) return null; try { const url = new URL(endpoint); url.pathname = url.pathname.replace(/\/chat\/completions$/, "").replace(/\/$/, ""); return url.toString().replace(/\/$/, ""); } catch { return endpoint.replace(/\/chat\/completions$/, "").replace(/\/$/, ""); } } function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, child]) => [key, canonicalize(child)])); } return value; } /** * Ask the OS for a currently-free localhost port (bind :0, read the assigned * port, release it). Skips the SDK's fixed default port and any port already * handed to another registry entry in this process, so coexisting servers * never contend. The probe-then-use gap is the standard free-port race — * acceptable here because the failure mode is a clean `spawn_failed` on the * next dispatch, not corruption. */ async function allocateFreePort(registryKey) { let release; const previous = _portAllocationTail; _portAllocationTail = new Promise((resolve) => { release = resolve; }); await previous; try { const { createServer } = await import("node:net"); const taken = new Set(_serverPorts.values()); for (let attempt = 0; attempt < 10; attempt++) { const port = await new Promise((resolve, reject) => { const probe = createServer(); probe.unref(); probe.on("error", reject); probe.listen(0, "127.0.0.1", () => { const address = probe.address(); probe.close(() => { if (address && typeof address === "object") resolve(address.port); else reject(new Error("could not read the probe socket's port")); }); }); }); if (port !== DEFAULT_SDK_PORT && !taken.has(port)) { _serverPorts.set(registryKey, port); return port; } } throw new Error("could not allocate a free port for the OpenCode SDK server"); } finally { release(); } } /** Grace between SIGTERM and SIGKILL when closing a managed server child. */ const SERVER_KILL_GRACE_MS = 2_000; // Test seam: override the argv used to spawn the server child ("opencode" // plus serve flags by default) so the managed-spawn lifecycle (handshake, // unref, SIGTERM→SIGKILL escalation) is testable without the real binary. let _serveCommand = null; /** Test-only seam: replace the `opencode serve` argv. Pass `null` to clear. */ export function __setServeCommand(argv) { _serveCommand = argv; } /** * Spawn-owning replacement for the SDK's `createOpencode` (module doc, * *Managed server spawn*). Mirrors `createOpencodeServer`'s contract — the * `spawn` receives the factory's immutable environment directly, * `OPENCODE_CONFIG_CONTENT` carries the config, the handshake parses the * "opencode server listening on <url>" line — but manages the child so its * handle can never pin akm's event loop: * * - handshake success → stdio destroyed, listeners dropped, `proc.unref()`; * - `close()` → SIGTERM now, SIGKILL after an unref'ed grace timer; * - handshake failure → the child is killed and unref'ed before rejecting. */ async function createManagedOpencode(options) { const { createOpencodeClient } = (await import("@opencode-ai/sdk").catch(() => { throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead."); })); const port = options.port ?? DEFAULT_SDK_PORT; const argv = _serveCommand ?? [options.bin ?? "opencode", "serve", "--hostname=127.0.0.1", `--port=${port}`]; const proc = spawn(argv[0], argv.slice(1), { env: options.env, stdio: ["ignore", "pipe", "pipe"], }); let closeStarted = false; let closeEscalation; const childExited = () => proc.exitCode !== null || proc.signalCode !== null; const clearCloseEscalation = () => { if (closeEscalation !== undefined) { clearTimeout(closeEscalation); closeEscalation = undefined; } }; const closeManaged = () => { if (closeStarted) return; closeStarted = true; if (childExited()) return; proc.once("exit", clearCloseEscalation); try { proc.kill("SIGTERM"); } catch { proc.off("exit", clearCloseEscalation); return; } if (childExited()) return; if (_processExiting) { // During `process.on("exit")` the event loop is finished, so a timer // scheduled here would never fire and the SIGKILL escalation could not // happen at all — a server child that ignores SIGTERM outlived akm. // Exit handlers must be synchronous, so escalate immediately instead of // waiting out a grace period we have no way to wait for. try { proc.kill("SIGKILL"); } catch { /* already dead */ } return; } closeEscalation = setTimeout(() => { closeEscalation = undefined; if (childExited()) return; try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, SERVER_KILL_GRACE_MS); }; const url = await new Promise((resolve, reject) => { let output = ""; let settled = false; const cleanupStartup = () => { proc.stdout?.off("data", onStdoutData); proc.stderr?.off("data", onStderrData); proc.off("exit", onExit); proc.off("error", onError); options.startupSignal.removeEventListener("abort", onAbort); }; const fail = (err) => { if (settled) return; settled = true; cleanupStartup(); closeManaged(); proc.stdout?.destroy(); proc.stderr?.destroy(); proc.unref(); reject(err); }; const succeed = (serverUrl) => { if (settled) return; settled = true; cleanupStartup(); resolve(serverUrl); }; const onStdoutData = (chunk) => { output += chunk.toString(); for (const line of output.split("\n")) { if (line.startsWith("opencode server listening")) { const match = line.match(/on\s+(https?:\/\/\S+)/); if (!match?.[1]) { fail(new Error(`Failed to parse the OpenCode server url from: ${line}`)); return; } succeed(match[1]); return; } } }; const onStderrData = (chunk) => { output += chunk.toString(); }; const onExit = (code, signal) => { const status = code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`; fail(new Error(`OpenCode server exited with ${status}${output.trim() ? `\nServer output: ${output}` : ""}`)); }; const onError = (err) => { fail(err instanceof Error ? err : new Error(String(err))); }; const onAbort = () => fail(new Error("OpenCode server startup cancelled because no callers are waiting")); proc.stdout?.on("data", onStdoutData); proc.stderr?.on("data", onStderrData); proc.on("exit", onExit); proc.on("error", onError); if (options.startupSignal.aborted) onAbort(); else options.startupSignal.addEventListener("abort", onAbort, { once: true }); }); // Handshake done: from here on the child must never hold akm open. Its // lifetime is managed explicitly (closeServer → closeManaged), not by the // event loop. Destroying the pipes also releases their loop handles. proc.stdout?.destroy(); proc.stderr?.destroy(); proc.unref(); return { client: createOpencodeClient({ baseUrl: url }), server: { close: closeManaged } }; } async function startServer(profile, sdkConfig, env, registryKey, startupSignal) { const factory = _serverFactory ?? createManagedOpencode; const options = { bin: profile.bin, ...(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}), env, startupSignal, }; // Every cached server receives a separately reserved port. This avoids both // inter-entry contention and collisions with an unrelated process on 4096. options.port = await allocateFreePort(registryKey); if (startupSignal.aborted) throw new Error("OpenCode server startup cancelled because no callers are waiting"); // Installed before the spawn, so a child that is still starting up is covered too. if (!_exitHookInstalled) { _exitHookInstalled = true; process.once("exit", () => { _processExiting = true; void closeServer(); }); // A command with no SIGINT/SIGTERM handler of its own dies on the signal without running `exit` hooks, which // orphaned every cached server. Close them and re-raise, so the default termination still happens. A command that // handles the signal itself (`workflow run`, `improve`, `index`, task runs) unwinds through its own cleanup and is // left alone. for (const signal of ["SIGINT", "SIGTERM"]) { if (process.listenerCount(signal) > 0) continue; process.once(signal, () => { _processExiting = true; void closeServer(); process.kill(process.pid, signal); }); } } const server = await factory(options); if (!server) throw new Error("Failed to initialise OpenCode SDK server."); return server; } /** * Get (or lazily start) the server for this call's complete server material. * Concurrent callers of the same key share one * start (the registry stores the in-flight promise). A failed start is * evicted so the next call can retry instead of caching the error forever. */ function getOrStartServer(profile, llmConfig, env, envSource = process.env, modelWork = false, inference) { if (_testServer) return { promise: Promise.resolve(_testServer), release() { } }; const sdkConfig = buildSdkConfig(profile, llmConfig, modelWork, inference); const serverEnv = buildServerEnv(profile, sdkConfig, env, envSource, modelWork); const key = serverRegistryKey(profile, serverEnv); let entry = _servers.get(key); if (!entry) { const controller = new AbortController(); entry = { promise: startServer(profile, sdkConfig, serverEnv, key, controller.signal), controller, waiters: 0, }; const started = entry; _servers.set(key, started); started.promise.then((server) => { if (_servers.get(key) === started) { started.server = server; _resolvedServers.set(key, server); } else { void server.server.close(); } }, () => { if (_servers.get(key) === started) { _servers.delete(key); _serverPorts.delete(key); } }); } entry.waiters++; let released = false; return { promise: entry.promise, release() { if (released) return; released = true; entry.waiters--; if (entry.waiters === 0 && !entry.server && _servers.get(key) === entry) { _servers.delete(key); _serverPorts.delete(key); entry.controller.abort(); } }, }; } /** * Extract best-effort token usage from a prompt response. Only numeric * fields the server actually reported are copied; returns undefined when * nothing usable is present (older servers, test fakes). */ function extractUsage(info) { const tokens = info?.tokens; if (!tokens) return undefined; const usage = {}; if (typeof tokens.input === "number" && Number.isFinite(tokens.input)) usage.inputTokens = tokens.input; if (typeof tokens.output === "number" && Number.isFinite(tokens.output)) usage.outputTokens = tokens.output; if (typeof tokens.reasoning === "number" && Number.isFinite(tokens.reasoning)) { usage.reasoningTokens = tokens.reasoning; } return Object.keys(usage).length > 0 ? usage : undefined; } const SDK_OPERATION_TIMED_OUT = Symbol("opencode-sdk-operation-timeout"); const SDK_OPERATION_ABORTED = Symbol("opencode-sdk-operation-aborted"); const SDK_SESSION_DELETE_TIMEOUT_MS = 5_000; async function raceSdkOperation(operation, opts) { let timer; let onAbort; let raceFinished = false; const racers = [operation]; void operation.then((value) => { if (raceFinished && opts.onLateSettle) void Promise.resolve(opts.onLateSettle({ status: "fulfilled", value })).catch(() => { }); }, (reason) => { if (raceFinished && opts.onLateSettle) void Promise.resolve(opts.onLateSettle({ status: "rejected", reason })).catch(() => { }); }); if (opts.timeoutMs !== null) { racers.push(new Promise((resolve) => { timer = opts.setTimeoutFn(() => resolve(SDK_OPERATION_TIMED_OUT), opts.timeoutMs ?? 0); if (typeof timer !== "number") timer.unref?.(); })); } if (opts.signal) { racers.push(new Promise((resolve) => { onAbort = () => resolve(SDK_OPERATION_ABORTED); if (opts.signal?.aborted) onAbort(); else opts.signal?.addEventListener("abort", onAbort, { once: true }); })); } try { return racers.length === 1 ? await operation : await Promise.race(racers); } finally { raceFinished = true; if (timer !== undefined) opts.clearTimeoutFn(timer); if (opts.signal && onAbort) opts.signal.removeEventListener("abort", onAbort); } } function errorText(err) { return err instanceof Error ? err.message : String(err); } function appendStderr(stderr, message) { return stderr ? `${stderr}\n${message}` : message; } /** * Map an SDK `{ error }` result or a reply's `info.error` onto a failure * (#1015). Both are opencode NamedErrors, `{ name, data: { message } }`. An * aborted message is `aborted`, a reply cut off at the output limit is * `parse_error`, and every other error (auth, API, unknown, an HTTP error * body) is `non_zero_exit`. */ function sdkErrorFailure(error) { if (!isRecord(error) || typeof error.name !== "string") { return { reason: "non_zero_exit", message: typeof error === "string" ? error : JSON.stringify(error) }; } const detail = isRecord(error.data) ? error.data.message : undefined; const message = typeof detail === "string" ? `${error.name}: ${detail}` : error.name; if (error.name === "MessageAbortedError") return { reason: "aborted", message }; if (error.name === "MessageOutputLengthError") return { reason: "parse_error", message }; return { reason: "non_zero_exit", message }; } async function deleteSessionBestEffort(client, sessionId, query, setTimeoutFn, clearTimeoutFn) { try { const deleted = await raceSdkOperation(client.session.delete({ path: { id: sessionId }, ...(query ? { query } : {}) }), { timeoutMs: SDK_SESSION_DELETE_TIMEOUT_MS, setTimeoutFn, clearTimeoutFn, }); if (deleted === SDK_OPERATION_TIMED_OUT) { return `OpenCode session cleanup timed out after ${SDK_SESSION_DELETE_TIMEOUT_MS}ms`; } return undefined; } catch (err) { return `OpenCode session cleanup failed: ${errorText(err)}`; } } /** Stop a server-side session that the dispatch has given up on, so it stops calling the model. */ function abortSessionBestEffort(client, sessionId, query) { void client.session.abort?.({ path: { id: sessionId }, ...(query ? { query } : {}) }).catch(() => { }); } function abortedBeforeSdkStart(profile) { return { ok: false, stdout: "", stderr: "", durationMs: 0, exitCode: null, reason: "aborted", error: `opencode-sdk agent "${profile.name}" not started: caller signal already aborted`, }; } export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) { const start = Date.now(); const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS; const deadline = timeoutMs === null ? null : start + timeoutMs; const remainingTimeoutMs = () => (deadline === null ? null : Math.max(0, deadline - Date.now())); const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout; const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout; if (opts.signal?.aborted) return abortedBeforeSdkStart(profile); const modelWork = opts.dispatch?.modelWork === true; let client; if (_testServer) { client = _testServer.client; } else { const startupHandle = getOrStartServer(profile, llmConfig, opts.env, opts.envSource, modelWork, opts.dispatch?.inference); try { const startup = await raceSdkOperation(startupHandle.promise, { timeoutMs: remainingTimeoutMs(), setTimeoutFn: setTimeoutImpl, clearTimeoutFn: clearTimeoutImpl, signal: opts.signal, }); if (startup === SDK_OPERATION_ABORTED) { return { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "aborted", error: `opencode-sdk agent "${profile.name}" aborted by caller signal during server startup`, }; } if (startup === SDK_OPERATION_TIMED_OUT) { return { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "timeout", error: `opencode-sdk agent "${profile.name}" timed out during server startup after ${timeoutMs}ms`, }; } client = startup.client; } catch (e) { return { ok: false, stdout: "", stderr: String(e), durationMs: Date.now() - start, exitCode: 1, reason: "spawn_failed", error: String(e), }; } finally { startupHandle.release(); } } // #564 bug fix (3): enforce a hard timeout like the CLI path (runAgent). // Previously runOpencodeSdk() awaited SDK calls with no timeout, so a stalled // local-model endpoint or wedged server could block the caller indefinitely. // The same absolute deadline covers server startup, session creation, and // prompting. null disables every dispatch timer. Session cleanup remains a // separately bounded best-effort operation. // Per-call working directory (module doc): forwarded as the SDK's // `query.directory` on every session call, so worktree-isolated units run // in their own checkout without a per-cwd server. const query = opts.cwd ? { directory: opts.cwd } : undefined; // One session per call — do NOT reuse (history accumulates, token costs grow). // Session creation is startup plumbing, so failures map to spawn_failed rather // than bubbling out as a generic workflow dispatch exception. const abortSignal = opts.signal; let sessionId; try { const created = await raceSdkOperation(client.session.create({ body: { title: "akm" }, ...(query ? { query } : {}) }), { timeoutMs: remainingTimeoutMs(), setTimeoutFn: setTimeoutImpl, clearTimeoutFn: clearTimeoutImpl, signal: abortSignal, onLateSettle: (late) => { if (late.status === "fulfilled" && late.value.data?.id) { void deleteSessionBestEffort(client, late.value.data.id, query, setTimeoutImpl, clearTimeoutImpl); } }, }); if (created === SDK_OPERATION_ABORTED) { return { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "aborted", error: `opencode-sdk agent "${profile.name}" aborted by caller signal`, }; } if (created === SDK_OPERATION_TIMED_OUT) { return { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "timeout", error: `opencode-sdk agent "${profile.name}" timed out creating a session after ${timeoutMs}ms`, }; } sessionId = created.data?.id; } catch (err) { return { ok: false, stdout: "", stderr: errorText(err), durationMs: Date.now() - start, exitCode: 1, reason: "spawn_failed", error: errorText(err), }; } if (!sessionId) { return { ok: false, stdout: "", stderr: "Failed to create session", durationMs: Date.now() - start, exitCode: 1, reason: "spawn_failed", error: "Failed to create OpenCode session", }; } // Forward the exact native agent selector, systemPrompt, and tools from the abstract // dispatch request. Both were previously accepted on AgentDispatchRequest but // silently dropped on the SDK path, so SDK-mode dispatch ignored agent-asset // system prompts and tool policies entirely (the CLI path honours both). // Model work runs the confined agent its server config defines; its tools are that agent's. const dispatch = opts.dispatch; const agent = modelWork ? MODEL_WORK_OPENCODE_AGENT : dispatch?.agent; const system = dispatch?.systemPrompt; const tools = toolsToSdkAllowlist(dispatch?.tools); const body = { parts: [{ type: "text", text: prompt }] }; if (agent) body.agent = agent; if (system) body.system = system; if (tools) body.tools = tools; let result; try { const prompted = await raceSdkOperation(client.session.prompt({ path: { id: sessionId }, body, ...(query ? { query } : {}) }), { timeoutMs: remainingTimeoutMs(), setTimeoutFn: setTimeoutImpl, clearTimeoutFn: clearTimeoutImpl, signal: abortSignal, onLateSettle: () => { void deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl); }, }); // A session the dispatch stops early is aborted on the server too. if (prompted === SDK_OPERATION_ABORTED || prompted === SDK_OPERATION_TIMED_OUT) { abortSessionBestEffort(client, sessionId, query); } if (prompted === SDK_OPERATION_ABORTED) { result = { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "aborted", error: `opencode-sdk agent "${profile.name}" aborted by caller signal`, sessionId, }; } else if (prompted === SDK_OPERATION_TIMED_OUT) { result = { ok: false, stdout: "", stderr: "", durationMs: Date.now() - start, exitCode: null, reason: "timeout", error: `opencode-sdk agent "${profile.name}" timed out after ${timeoutMs}ms`, sessionId, }; } else { const parts = prompted.data?.parts ?? []; // The last text part is the answer; earlier ones narrate the steps before it. const stdout = parts.filter((p) => p.type === "text").at(-1)?.text ?? ""; // Token accounting from the AssistantMessage (previously discarded) — // the seam that makes workflow budget.maxTokens meterable on the // default sdk runner. const usage = extractUsage(prompted.data?.info); const sdkError = prompted.error ?? prompted.data?.info?.error; if (sdkError) { const failure = sdkErrorFailure(sdkError); result = { ok: false, stdout, stderr: failure.message, durationMs: Date.now() - start, exitCode: failure.reason === "aborted" ? null : 1, reason: failure.reason, error: failure.message, sessionId, ...(usage ? { usage } : {}), }; } else { result = { ok: true, stdout, stderr: "", durationMs: Date.now() - start, exitCode: 0, sessionId, ...(usage ? { usage } : {}), }; } } } catch (err) { result = { ok: false, stdout: "", stderr: errorText(err), durationMs: Date.now() - start, exitCode: 1, reason: "non_zero_exit", error: errorText(err), sessionId, }; } // Clean up session to prevent disk accumulation in ~/.local/share/opencode/. // Failures are non-fatal to the agent result but must not be invisible. const cleanupWarning = await deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl); if (cleanupWarning) result.stderr = appendStderr(result.stderr, cleanupWarning); return result; }