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,154 lines • 50.2 kB
JavaScript
// 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`).
*
* ## Sessions are kept (#1100)
*
* One session is created per dispatch and is NEVER deleted: it stays in
* OpenCode's history (transcript for debugging, input for session capture),
* as it does for `opencode run`, and its id is returned as
* `AgentRunResult.sessionId`. A dispatch akm gives up on (timeout or caller
* abort) is aborted server-side so the server stops calling the model, but the
* session is still kept. The server process itself is closed independently
* (see the registry notes below).
*
* ## 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.abort` 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, OPENCODE_ENV_PASSTHROUGH, spawnEnvNamesFor } 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 OpenCode names the `opencode` CLI profile passes
* (`OPENCODE_ENV_PASSTHROUGH`: API key, config path, XDG base directories), and
* the profile's own names. They are the server's, not the profile's: there is no
* built-in `opencode-sdk` profile, so its `envPassthrough` is empty.
*/
export function opencodeSdkServerEnvironmentNames(profile) {
return [
...new Set([
...spawnEnvNamesFor(COMMON_SPAWN_ENV_PASSTHROUGH),
...OPENCODE_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();
const client = createOpencodeClient({ baseUrl: url });
client.event = { subscribe: (args) => subscribeEvents(url, args) };
return { client, server: { close: closeManaged } };
}
/**
* Subscribe to the server's event stream. The SDK's own `event.subscribe`
* cannot be closed safely: aborting it leaves an unhandled `AbortError`
* rejection (from its un-awaited `reader.cancel()`), which akm's global
* handler turns into a process exit. This reader ends quietly on abort.
*/
async function subscribeEvents(baseUrl, args) {
const eventUrl = new URL("/event", baseUrl);
if (args.query?.directory)
eventUrl.searchParams.set("directory", args.query.directory);
const response = await fetch(eventUrl, { signal: args.signal, headers: { accept: "text/event-stream" } });
if (!response.ok || !response.body)
throw new Error(`OpenCode event stream failed: HTTP ${response.status}`);
const reader = response.body.getReader();
async function* stream() {
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done)
return;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s*/, ""))
.join("\n");
if (!data)
continue;
try {
yield JSON.parse(data);
}
catch {
/* not JSON: not an event we act on */
}
}
}
}
catch (err) {
if (!args.signal.aborted)
throw err;
}
}
return { stream: stream() };
}
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");
async function raceSdkOperation(operation, opts) {
let timer;
let onAbort;
const racers = [operation];
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 {
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);
}
/**
* 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 };
}
/** 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(() => { });
}
/**
* Answer every OpenCode permission request of the dispatch's session (and of
* the sub-sessions it spawns) with "reject", once each. This is what a
* non-interactive `opencode run` does ("auto-rejecting"); left unanswered, a
* tool call that needs permission (OpenCode's default `external_directory:
* ask`) stays `running` and the prompt never settles. A rejection reaches the
* model as a tool error it can work around. Each request and answer is
* appended to `notes`. The same stream reports OpenCode's retries (#1108):
* each is noted, and one caused by a usage limit, or due after `deadline`,
* ends the run through `stopForRetry` instead of a silent wait. `ready` (absent when the client has no event surface)
* settles once the subscription is open and never rejects; `stop` closes it.
*/
function rejectPermissionRequests(client, sessionId, query, notes, deadline, stopForRetry) {
const subscribe = client.event?.subscribe;
const reply = client.postSessionIdPermissionsPermissionId;
const controller = new AbortController();
const stop = () => controller.abort();
if (!subscribe || !reply)
return { stop };
const sessions = new Set([sessionId]);
const answered = new Set();
const consume = async (stream) => {
for await (const event of stream) {
if (!isRecord(event) || !isRecord(event.properties))
continue;
const props = event.properties;
if (event.type === "session.created" && isRecord(props.info)) {
const { id, parentID } = props.info;
if (typeof id === "string" && typeof parentID === "string" && sessions.has(parentID))
sessions.add(id);
continue;
}
if (event.type === "session.status") {
const { sessionID, status } = props;
if (typeof sessionID !== "string" || !sessions.has(sessionID) || !isRecord(status) || status.type !== "retry") {
continue;
}
const attempt = typeof status.attempt === "number" ? status.attempt : "unknown";
const message = typeof status.message === "string" ? status.message : "OpenCode is retrying";
const next = typeof status.next === "number" ? status.next : undefined;
const retryAt = next === undefined ? "unknown time" : new Date(next).toISOString();
// `action` is not present in the SDK's retry type yet, but current
// OpenCode servers include it for provider/account limits.
const action = isRecord(status.action) ? status.action : undefined;
const provider = typeof action?.provider === "string" ? action.provider : undefined;
notes.push(`OpenCode retry attempt ${attempt}${provider ? ` (${provider})` : ""}: ${message}; retrying at ${retryAt}`);
if (action?.reason === "account_rate_limit") {
stopForRetry(`OpenCode provider limit stopped this run${provider ? ` (${provider})` : ""}: ${message}; reset at ${retryAt}`);
}
else if (next !== undefined && deadline !== null && next > deadline) {
stopForRetry(`OpenCode's next retry (${retryAt}) is after this run's deadline: ${message}`);
}
continue;
}
// `permission.asked` (OpenCode >= 1.3) / `permission.updated` (older).
if (event.type !== "permission.asked" && event.type !== "permission.updated")
continue;
const { id, sessionID } = props;
if (typeof id !== "string" || typeof sessionID !== "string")
continue;
if (!sessions.has(sessionID) || answered.has(id))
continue;
answered.add(id);
const patterns = Array.isArray(props.patterns) ? props.patterns : props.pattern ? [props.pattern] : [];
notes.push(`permission requested: ${String(props.permission ?? props.type ?? "unknown")} (${patterns.join(", ")}); auto-rejecting`);
reply
.call(client, {
path: { id: sessionID, permissionID: id },
body: { response: "reject" },
...(query ? { query } : {}),
})
.then((r) => {
if (r.error)
notes.push(`permission reply for ${id} failed: ${JSON.stringify(r.error)}`);
})
.catch((err) => notes.push(`permission reply for ${id} failed: ${errorText(err)}`));
}
};
const ready = subscribe
.call(client.event, { ...(query ? { query } : {}), signal: controller.signal })
.then(({ stream }) => {
void consume(stream).catch((err) => {
if (!controller.signal.aborted)
notes.push(`permission event stream ended: ${errorText(err)}`);
});
})
.catch((err) => {
notes.push(`permission requests will not be answered: event subscription failed: ${errorText(err)}`);
});
return { ready, stop };
}
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.
// 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 callerSignal = opts.signal;
const runController = new AbortController();
const abortForCaller = () => runController.abort();
if (callerSignal?.aborted)
abortForCaller();
else
callerSignal?.addEventListener("abort", abortForCaller, { once: true });
const abortSignal = runController.signal;
let sessionId;
try {
const created = await raceSdkOperation(client.session.create({ body: { title: "akm" }, ...(query ? { query } : {}) }), {
timeoutMs: remainingTimeoutMs(),
setTimeoutFn: setTimeoutImpl,
clearTimeoutFn: clearTimeoutImpl,
signal: abortSignal,
});
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;
// Subscribed before the prompt so no request can be missed; closed in `finally`.
const permissionNotes = [];
let retryFailure;
const permissions = rejectPermissionRequests(client, sessionId, query, permissionNotes, deadline, (failure) => {
retryFailure ??= failure;
runController.abort();
});
try {
// Bounded like every other step; if it times out the prompt race below does too.
if (permissions.ready) {
await raceSdkOperation(permissions.ready, {
timeoutMs: remainingTimeoutMs(),
setTimeoutFn: setTimeoutImpl,
clearTimeoutFn: clearTimeoutImpl,
signal: abortSignal,
});
}
const prompted = await raceSdkOperation(client.session.prompt({ path: { id: sessionId }, body, ...(query ? { query } : {}) }), {
timeoutMs: remainingTimeoutMs(),
setTimeoutFn: setTimeoutImpl,
clearTimeoutFn: clearTimeoutImpl,
signal: abortSignal,
});
// 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 && retryFailure) {
result = {
ok: false,
stdout: "",
stderr: retryFailure,
durationMs: Date.now() - start,
exitCode: 1,
reason: "llm_rate_limit",
error: retryFailure,
sessionId,
};
}
else 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,
};
}
finally {
permissions.stop();
callerSignal?.removeEventListener("abort", abortForCaller);
}
if (permissionNotes.length > 0) {
result.stderr = [result.stderr, ...permissionNotes].filter(Boolean).join("\n");
}
return result;
}