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,154 lines • 50.2 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`).
 *
 * ## 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;
}