Official
opencode
OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0
Permissions in 0.2.0
Files
client.ts25.1 KB
// The `opencode serve` child and the HTTP calls made against a server
// (the Rust `client.rs`). The child runs through the `Process` service,
// the calls go through `Net` (the `localhost:*` grant), every one with the
// server's Basic credentials.
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
import * as z from "zod";
import { Net, Process } from "convergence/effect";
import type { ChildProcess, Response } from "convergence";
import { lines } from "../sdk/jsonrpc.ts";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import { obj } from "./map.ts";
/// How long the server may take to print its listen address.
export const START_TIMEOUT = 30_000;
/// How long `opencode --version` may take. It prints and exits.
const VERSION_TIMEOUT = 15_000;
/// How long `GET /global/health` may take before the server counts as dead.
export const HEALTH_TIMEOUT = 5_000;
/// How long the process group may take to exit on `SIGTERM`.
const STOP_GRACE = 1_000;
/// Lines of child output kept for the failure message.
const LOG_TAIL = 20;
/// Longest line accepted while looking for the listen address.
const MAX_LINE = 8 * 1024;
/// Ports tried before starting gives up: a port chosen at random can be
/// taken between the choice and the bind.
const START_ATTEMPTS = 3;
export interface Auth {
username: string;
password: string;
}
/// The oldest server this plugin speaks to. Older builds miss the
/// `question` family and the `variant` prompt field.
export const MINIMUM_VERSION = "1.14.19";
/// The default Basic-auth user of an OpenCode server, which is what the
/// server itself assumes when `OPENCODE_SERVER_USERNAME` is unset.
export const DEFAULT_USERNAME = "opencode";
export type TroubleKind = "not_installed" | "too_old" | "unsupported" | "auth_rejected";
/// A failure the UI describes in its own words, because the fix differs:
/// install the program, upgrade it, point at a 1.x binary, correct the
/// password. `kind`: `not_installed`, `too_old`, `unsupported`,
/// `auth_rejected`.
export class Trouble extends Error {
kind: TroubleKind;
details: Record<string, unknown>;
constructor(kind: TroubleKind, details: Record<string, unknown> = {}) {
super(troubleMessage(kind, details));
this.name = "Trouble";
this.kind = kind;
this.details = details;
}
}
function troubleMessage(kind: string, { found, binary, version }: Record<string, unknown> = {}): string {
switch (kind) {
case "not_installed":
return "the `opencode` binary was not found on PATH; install opencode to use this agent";
case "too_old":
return `opencode ${found} is too old; Divergence needs ${MINIMUM_VERSION} or newer. Upgrade with \`opencode upgrade\`.`;
case "unsupported":
return (
`${binary} is OpenCode ${version}, which serves a different API from the 1.x one Divergence speaks. ` +
"Put a 1.x `opencode` first on your login PATH (for example ~/.opencode/bin, where the install script puts it)."
);
case "auth_rejected":
return (
"the opencode server rejected the password; set CONVERGENCE_OPENCODE_SERVER_PASSWORD to the password " +
"that server was started with"
);
default:
return kind;
}
}
const remoteSchema = z.looseObject({
_tag: z.unknown().optional(),
name: z.unknown().optional(),
trouble: z.unknown().optional(),
});
/// Whether an error is the host refusing a grant (Effect's tagged form or
/// the prelude's `name` form): such a failure must travel unchanged, so the
/// caller can tell it apart from a server failure.
export function isRefusal(error: unknown): boolean {
const parsed = remoteSchema.safeParse(error);
if (!parsed.success) return false;
return parsed.data._tag === "PermissionNotGranted" || parsed.data.name === "PermissionNotGranted";
}
/// The trouble behind an error, when it is one of the four.
export function troubleOf(error: unknown): Trouble | null {
if (error instanceof Trouble) return error;
const parsed = remoteSchema.safeParse(error);
const trouble = parsed.success ? parsed.data.trouble : undefined;
return trouble instanceof Trouble ? trouble : null;
}
// --- credentials ---------------------------------------------------------------
/// The password rule for a server: `CONVERGENCE_OPENCODE_SERVER_PASSWORD`
/// is a deliberate choice and applies wherever the server runs;
/// `OPENCODE_SERVER_PASSWORD` is the ambient value the local server is
/// started with, and sending it to a remote host would hand that host a
/// local secret. `null` for a local server means "none configured" (the
/// caller makes one up), never "no password".
export function passwordFor(external: boolean, configured: string | null, ambient: string | null): string | null {
if (configured) return configured;
if (external) return null;
return ambient || null;
}
/// A password nobody chose, for a server this plugin starts: OpenCode no
/// longer accepts anonymous requests, so a server started without
/// `OPENCODE_SERVER_PASSWORD` would refuse us. It never touches disk or a
/// command line (it goes to the child's environment), and it is new on
/// every launch, from the runtime's `crypto`.
export function generatedPassword(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
/// The `Authorization` header for Basic credentials.
export function basicAuth(auth: Auth | null): string | null {
if (!auth) return null;
const raw = `${auth.username}:${auth.password}`;
// Latin-1 only for btoa; a password with other characters goes as UTF-8.
const bytes = new TextEncoder().encode(raw);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
// --- versions ---------------------------------------------------------------------
/// The leading `major.minor.patch` of a version, ignoring a pre-release or
/// build suffix; `null` when it does not parse.
export function semver(value: unknown): [number, number, number] | null {
if (typeof value !== "string") return null;
const core = value.trim().replace(/^v/, "").split(/[-+]/)[0] ?? "";
const parts = core.split(".");
const number = (part: string | undefined): number | null =>
part !== undefined && /^\d+$/.test(part) ? Number(part) : null;
const major = number(parts[0]);
const minor = parts.length > 1 ? number(parts[1]) : 0;
const patch = parts.length > 2 ? number(parts[2]) : 0;
if (major === null || minor === null || patch === null) return null;
return [major, minor, patch];
}
function compare(a: [number, number, number], b: [number, number, number]): number {
if (a[0] !== b[0]) return a[0] - b[0];
if (a[1] !== b[1]) return a[1] - b[1];
return a[2] - b[2];
}
/// Refuses a server older than `MINIMUM_VERSION`. A version that does not
/// parse is trusted: refusing an unusual build is worse than running it.
export function checkVersion(reported: unknown): void {
const found = semver(reported);
const minimum = semver(MINIMUM_VERSION);
if (found && minimum && compare(found, minimum) < 0) throw new Trouble("too_old", { found: reported });
}
/// The version in a `--version` line: `1.18.29` or `opencode v2.0.1`.
export function versionIn(line: unknown): string | null {
const words = String(line ?? "")
.split(/\s+/)
.filter(Boolean)
.reverse();
for (const word of words) if (semver(word)) return word.replace(/^v/, "");
return null;
}
/// Whether a version belongs to the 1.x line this plugin speaks: 2.0 moved
/// the whole HTTP API under `/api` with new shapes.
export function speaksOurApi(version: unknown): boolean {
const parsed = semver(version);
return parsed === null || parsed[0] < 2;
}
// --- the listen address -------------------------------------------------------------
/// The listen address from a startup line, which reads `opencode server
/// listening on http://127.0.0.1:1234`: anchored on the scheme and ended
/// at the first whitespace, so a line that merely mentions a URL later is
/// not mistaken for it.
export function parseListenUrl(line: unknown): string | null {
const text = String(line ?? "");
let start = text.indexOf("http://");
if (start < 0) start = text.indexOf("https://");
if (start < 0) return null;
const rest = text.slice(start);
const end = rest.search(/\s/);
const url = (end < 0 ? rest : rest.slice(0, end)).replace(/[/.,]+$/, "");
const authority = url.split("//")[1] ?? "";
return authority ? url : null;
}
/// Whether the address a server reported is the one it was asked for.
/// OpenCode 1.18 answers `--port 0` with its default 4096, which is why
/// the plugin picks the port itself.
export function listensOn(url: unknown, port: number): boolean {
const at = String(url).lastIndexOf(":");
if (at < 0) return false;
const tail = String(url)
.slice(at + 1)
.replace(/\/+$/, "");
return /^\d+$/.test(tail) && Number(tail) === port;
}
/// A port to ask for: the plugin cannot bind one to test it, so it picks
/// at random outside the ranges servers usually claim, and a start that
/// finds it taken tries another.
export function randomPort(): number {
const bytes = crypto.getRandomValues(new Uint8Array(2));
const high = bytes[0] ?? 0;
const low = bytes[1] ?? 0;
return 20_000 + (((high << 8) | low) % 30_000);
}
/// Whether a loopback host: the tools server the host serves is reachable
/// from a server on this machine only.
export function isLoopback(base: unknown): boolean {
const match = /^https?:\/\/(\[[^\]]*\]|[^/:]+)/i.exec(String(base ?? ""));
if (!match) return false;
const host = (match[1] ?? "").toLowerCase();
return host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
}
// --- HTTP -----------------------------------------------------------------------------
export interface RequestOptions {
query?: Record<string, unknown>;
body?: unknown;
empty?: boolean;
timeout?: number;
}
/// The query string for `query` (`{ directory: "/w" }`), entries whose
/// value is `null` or `undefined` left out.
export function queryString(query: Record<string, unknown> = {}): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue;
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return parts.length ? `?${parts.join("&")}` : "";
}
export interface Refused extends Error {
trouble?: Trouble;
status?: number;
}
/// The error for a response the server refused. A rejected password is
/// told apart here, because every later caller sees the same 401 and only
/// this layer knows it came from the credentials.
export function failure(status: number, method: string, path: string, body: unknown): Refused {
const error: Refused = new Error(`opencode ${method} ${path} failed with ${status}: ${String(body ?? "").trim()}`);
if (status === 401 || status === 403) error.trouble = new Trouble("auth_rejected");
error.status = status;
return error;
}
/// One request to the server at `base`. The answer is parsed as JSON;
/// `empty: true` ignores the body (many endpoints answer 204). `timeout`
/// (ms) gives up waiting: the runtime cannot cancel a request before its
/// answer arrives, so the request itself may still finish on the server.
export const request = Effect.fn("OpenCode.request")(function* (
base: string,
auth: Auth | null,
method: string,
path: string,
{ query, body, empty = false, timeout = 0 }: RequestOptions = {},
) {
const net = yield* Net;
const headers: Record<string, string> = { accept: "application/json" };
const authorization = basicAuth(auth);
if (authorization) headers["authorization"] = authorization;
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
if (body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
const url = `${base}${path}${queryString(query)}`;
const exchange = Effect.gen(function* () {
const response = yield* net.fetch(url, init).pipe(
Effect.mapError((error) => {
// A refused grant travels unchanged, so the caller can tell it
// apart from a server failure.
if (isRefusal(error)) return error;
const failed: Refused = new Error(`${method} ${path}: ${errorMessage(error)}`);
return failed;
}),
);
const text = yield* Effect.tryPromise({
try: () => response.text(),
catch: (error) => {
const failed: Refused = new Error(`${method} ${path}: ${errorMessage(error)}`);
return failed;
},
});
if (!response.ok) return yield* Effect.fail(failure(response.status, method, path, text));
if (empty) return null;
try {
const parsed: unknown = JSON.parse(text);
return parsed;
} catch {
// The status and the start of the body go into the error: "not JSON"
// alone says nothing about what the server sent.
const type = response.headers?.get?.("content-type") ?? "";
const failed: Refused = new Error(
`decoding the response of ${method} ${path} (status ${response.status}, ${type}, ${text.length} bytes: ${JSON.stringify(text.slice(0, 120))})`,
);
return yield* Effect.fail(failed);
}
});
if (!timeout) return yield* exchange;
return yield* exchange.pipe(
Effect.timeoutOrElse({
duration: timeout,
orElse: () => {
const failed: Refused = new Error(
`opencode ${method} ${path} did not answer within ${Math.round(timeout / 1000)}s`,
);
return Effect.fail(failed);
},
}),
);
});
/// Opens the server's event stream for `directory`: the response, whose
/// `body` the caller reads as server-sent events. Carries the credentials
/// like every other request: it once went out bare, which a server with a
/// password answered with 401 on every reconnect, for ever.
export const openEvents = Effect.fn("OpenCode.openEvents")(function* (
base: string,
auth: Auth | null,
directory: string,
) {
const net = yield* Net;
const headers: Record<string, string> = { accept: "text/event-stream" };
const authorization = basicAuth(auth);
if (authorization) headers["authorization"] = authorization;
const response: Response = yield* net.fetch(`${base}/event${queryString({ directory })}`, { headers }).pipe(
Effect.mapError((error) => {
if (isRefusal(error)) return error;
const failed: Refused = new Error(`GET /event: ${errorMessage(error)}`);
return failed;
}),
);
if (!response.ok) {
const text = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new Error("text") }).pipe(
Effect.orElseSucceed(() => ""),
);
return yield* Effect.fail(failure(response.status, "GET", "/event", text));
}
return response;
});
// --- the local server ------------------------------------------------------------------
/// What `opencode --version` says, when it answers in time.
export const reportedVersion = Effect.fn("OpenCode.reportedVersion")(function* (binary: string) {
const result = yield* runProcess(binary, ["--version"], { timeout: VERSION_TIMEOUT }).pipe(
Effect.scoped,
Effect.catchTag("TransportFailed", () => Effect.succeed(null)),
Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview", "ParseFailed"], (error) => {
if (isRefusal(error)) return Effect.fail(notGranted(binary, error));
return Effect.succeed(null);
}),
);
if (!result) return null;
return versionIn(result.stdout);
});
/// A binary the plugin may not start: a path the user chose, which a
/// `process` grant (bare program names only) does not cover.
function notGranted(binary: string, error: unknown): Error {
return new Error(
`Divergence can start OpenCode only as \`opencode\` from the login PATH; starting ${binary} needs a grant ` +
`this plugin does not ask for (${errorMessage(error)}). Remove the binary override, or put that binary first on the PATH.`,
);
}
/// Drains a pipe line by line, recording each one, until it ends.
function drain(pipe: ChildProcess["stdout"] | ChildProcess["stderr"], record: (line: string) => void) {
return (async () => {
try {
for await (const line of lines(pipe)) record(line);
} catch {
// A broken pipe only loses the explanation.
}
})();
}
/// A `opencode serve` this plugin started. `start` waits until it answers
/// `GET /global/health` with a version it supports. The server owns a
/// scope: spawning, draining and stopping all end with it.
export class LocalServer {
child: ChildProcess;
scope: Scope.Closeable;
port: number;
base = "";
version = "";
log: string[] = [];
exitStatus: { code: number | null; signal: number | string | null } | null = null;
constructor(child: ChildProcess, scope: Scope.Closeable, port: number) {
this.child = child;
this.scope = scope;
this.port = port;
void Promise.resolve(child.exited).then((status) => {
this.exitStatus = status ?? { code: null, signal: null };
return this.exitStatus;
});
}
/// Starts a server from `binary` (the `opencode` on the login PATH when
/// `null`) with the password in `auth`. `describe` names the binary in a
/// message about a 2.x install.
static start(
auth: Auth | null,
{
binary = null,
describe = null,
}: {
binary?: string | null;
describe?: (() => Effect.Effect<string | null, unknown, Net | Process>) | null;
} = {},
) {
return Effect.fn("OpenCode.start")(function* () {
const program = binary ?? "opencode";
// Asked before the server starts: a 2.x binary starts fine and then
// serves its web page where the 1.x API used to be, which no health
// check can explain. Its own version line can.
const version = yield* reportedVersion(program);
if (version && !speaksOurApi(version)) {
let where: string | null = binary;
if (!where && describe) {
const settled = yield* Effect.result(describe());
if (Result.isSuccess(settled)) where = settled.success;
}
where ??= "the opencode on the login PATH";
return yield* Effect.fail(new Trouble("unsupported", { binary: where, version }));
}
let last: unknown = null;
for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
const port = randomPort();
const launched = yield* LocalServer.launch(program, port, auth, binary).pipe(Effect.result);
if (Result.isSuccess(launched)) return launched.success;
last = launched.failure;
// Only a taken port is worth another try.
if (!/EADDRINUSE|address already in use|in use|another server may own/i.test(errorMessage(last)))
return yield* Effect.fail(last);
}
return yield* Effect.fail(last);
})();
}
static launch(program: string, port: number, auth: Auth | null, binary: string | null) {
return Effect.fn("OpenCode.launch")(function* () {
const process = yield* Process;
// The server owns a scope: its child, drains and watches all end
// with it.
const scope = Scope.makeUnsafe();
const child = yield* process
.spawn(program, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
// The server reads its own password from the environment; passing
// it here secures the server we start.
env: auth ? { OPENCODE_SERVER_PASSWORD: auth.password } : undefined,
})
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.mapError((error) => {
if (isRefusal(error) && binary) return notGranted(binary, error);
if (/not on the PATH|No such file|not found/i.test(errorMessage(error)))
return new Trouble("not_installed");
return new Error(`failed to start ${program}: ${errorMessage(error)}`);
}),
);
try {
yield* Effect.tryPromise({ try: () => child.stdin.close(), catch: () => new Error("close") }).pipe(
Effect.ignore,
);
} catch {
// The server does not read its input.
}
const server = new LocalServer(child, scope, port);
const url = yield* server.listenUrl();
if (!listensOn(url, port)) {
yield* server.stop();
return yield* Effect.fail(
new Error(
`opencode serve was asked for port ${port} but reported ${url}; another server may own that address`,
),
);
}
server.base = url;
const checked = yield* request(server.base, auth, "GET", "/global/health", { timeout: HEALTH_TIMEOUT }).pipe(
Effect.result,
);
if (Result.isFailure(checked)) {
yield* server.stop();
return yield* Effect.fail(
new Error(`opencode server health check failed: ${errorMessage(checked.failure)}${server.logTail()}`),
);
}
const health: unknown = checked.success;
const supported = yield* Effect.try({
try: () => checkVersion(obj(health)["version"]),
catch: (error) => error,
}).pipe(Effect.result);
if (Result.isFailure(supported)) {
yield* server.stop();
return yield* Effect.fail(supported.failure);
}
server.version = String(obj(health)["version"] ?? "");
return server;
})();
}
record(line: string): void {
if (this.log.length === LOG_TAIL) this.log.shift();
this.log.push(line.slice(0, MAX_LINE));
}
/// Reads stdout until the listen address appears, and keeps draining
/// both pipes afterwards so the child never blocks on a full one.
listenUrl() {
return Effect.fn("OpenCode.listenUrl")(
function* (this: LocalServer) {
const server = this;
// Both pipes are drained for as long as the child runs, so it never
// blocks on a full one.
yield* Effect.forkIn(server.scope)(
Effect.promise(() => drain(server.child.stderr, (line) => server.record(line))),
);
const found = yield* Deferred.make<string, Error>();
const watch = Effect.promise(async () => {
try {
for await (const line of lines(server.child.stdout)) {
server.record(line);
if (line.length > MAX_LINE) continue;
const url = parseListenUrl(line);
if (url) {
Deferred.doneUnsafe(found, Effect.succeed(url));
// Keep draining: the child runs on.
}
}
// The child ended its output before naming an address: wait for
// the exit (and the stderr drain) before naming what it printed.
await server.child.exited.catch(() => null);
Deferred.doneUnsafe(
found,
Effect.fail(new Error(`opencode serve exited before it reported a listen address${server.logTail()}`)),
);
} catch {
Deferred.doneUnsafe(
found,
Effect.fail(new Error(`opencode serve exited before it reported a listen address${server.logTail()}`)),
);
}
});
yield* Effect.forkIn(server.scope)(watch.pipe(Effect.ignore));
const outcome = yield* Deferred.await(found).pipe(
Effect.timeoutOrElse({
duration: START_TIMEOUT,
orElse: () =>
Effect.fail(
new Error(
`opencode serve did not report a listen address within ${START_TIMEOUT / 1000}s${server.logTail()}`,
),
),
}),
Effect.result,
);
if (Result.isFailure(outcome)) {
yield* server.stop();
return yield* Effect.fail(outcome.failure);
}
return outcome.success;
}.bind(this),
)();
}
/// Whether the child still runs. A server that died leaves every later
/// request failing with a connection error, so callers restart it.
get alive(): boolean {
return this.exitStatus === null;
}
/// The last lines the child printed, ready to append to an error.
logTail(): string {
return this.log.length ? `; last output: ${this.log.join(" | ")}` : "";
}
/// Stops the whole process group (`opencode serve` forks workers a
/// single kill would orphan): `SIGTERM`, then `SIGKILL` after a second.
/// The `SIGTERM` is the spawn finalizer's: closing the server's scope
/// stops the child, so a stop is exactly one signal and an unload stops
/// a server nobody stopped. Ends the server's scope, so its drains end
/// with it.
stop() {
return Effect.fn("OpenCode.stop")(
function* (this: LocalServer) {
if (this.exitStatus !== null) return;
yield* Scope.close(this.scope, Exit.void).pipe(Effect.ignore);
const exited = yield* Effect.raceFirst(
Effect.tryPromise({ try: () => this.child.exited, catch: (error) => error }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
),
Effect.sleep(STOP_GRACE).pipe(Effect.as(false)),
);
if (!exited)
yield* Effect.tryPromise({ try: () => this.child.kill("SIGKILL"), catch: () => new Error("kill") }).pipe(
Effect.ignore,
);
}.bind(this),
)();
}
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.8 KB | 5 permissions | Listed |
No comments yet.