Official
opencode-v2
OpenCode 2 agent provider: runs opencode serve (2.x) and talks to its /api 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-v2@0.1.0
Permissions in 0.1.0
Files
server.ts12.8 KB
// The OpenCode 2 server: the `opencode serve` child this plugin starts (or
// the address of one the user runs), and the HTTP requests made against
// it with its Basic credentials. Every call goes through `Net` (the
// `localhost:*` grant) and the child through `Process`.
import * as Data from "effect/Data";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Scope from "effect/Scope";
import * as z from "zod";
import { Net, Process } from "convergence/effect";
import type { ChildProcess } from "convergence";
import { lines } from "../sdk/jsonrpc.ts";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import * as api from "./api.ts";
/// How long the server may take to print its listen address.
const START_TIMEOUT = 30_000;
/// How long `opencode --version` may take. It prints and exits.
const VERSION_TIMEOUT = 15_000;
/// How long `GET /api/info` may take before the new server counts as dead.
const INFO_TIMEOUT = 5_000;
/// Lines of child output kept for a failure message.
const LOG_TAIL = 20;
/// The user name an OpenCode server assumes when none is configured.
export const DEFAULT_USERNAME = "opencode";
export interface Auth {
username: string;
password: string;
}
/// A request the server refused or did not answer. `status` is the HTTP
/// status, absent when no answer came.
export class ServerError extends Data.TaggedError("ServerError")<{
message: string;
status?: number;
refusalKind?: string;
}> {}
/// The binary is not a 2.x OpenCode, or there is none. The message says
/// what to change, because the fix is the user's.
export class Unusable extends Data.TaggedError("Unusable")<{ message: string }> {}
// --- credentials ---------------------------------------------------------------------
/// The password for a server: `CONVERGENCE_OPENCODE_SERVER_PASSWORD` is a
/// deliberate choice and holds wherever the server runs, while
/// `OPENCODE_SERVER_PASSWORD` is the ambient value a local server starts
/// with and must never be sent to a server somebody else runs. `null` for a
/// local server means "make one up", never "no password": OpenCode refuses
/// anonymous requests.
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. It goes to the
/// child's environment only, and is new on every launch.
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, UTF-8 encoded.
export function basicAuth(auth: Auth | null): string | null {
if (!auth) return null;
let binary = "";
for (const byte of new TextEncoder().encode(`${auth.username}:${auth.password}`)) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
// --- versions and addresses ---------------------------------------------------------------
/// The major version in a `--version` line (`opencode v2.0.16`, `1.18.29`).
export function majorVersion(line: string): number | null {
for (const word of line.trim().split(/\s+/).reverse()) {
const match = /^v?(\d+)\.\d+(\.\d+)?/.exec(word);
if (match?.[1]) return Number(match[1]);
}
return null;
}
/// The listen address in the startup line (`server listening on
/// http://127.0.0.1:56931`): anchored on the scheme and ended at the first
/// whitespace, so a line that only mentions a URL later is not taken.
export function parseListenUrl(line: string): string | null {
const match = /\bhttps?:\/\/[^\s/]+/.exec(line);
return match && /listening/i.test(line) ? match[0] : null;
}
/// Whether the server is on this machine's loopback: the host's tools
/// server answers there only.
export function isLoopback(base: string): boolean {
const match = /^https?:\/\/(\[[^\]]*\]|[^/:]+)/i.exec(base);
const host = (match?.[1] ?? "").toLowerCase();
return host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
}
/// `?a=1&location[directory]=/w`, the query of a request. Values that are
/// `undefined` are left out.
export function queryString(query: Record<string, string | number | undefined>): string {
const parts = Object.entries(query)
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
return parts.length ? `?${parts.join("&")}` : "";
}
/// The query that scopes a catalog call to a workspace folder (OpenAPI
/// `deepObject` style).
export const atLocation = (directory: string) => ({ "location[directory]": directory });
// --- requests ------------------------------------------------------------------------------
export interface RequestOptions {
query?: Record<string, string | number | undefined>;
body?: unknown;
timeout?: number;
}
const serverFailure = z.looseObject({ _tag: z.string().optional(), message: z.string().optional() });
/// What the server said when it refused a request: its tagged error's
/// message when the body is one, the start of the body otherwise.
function refusal(method: string, path: string, status: number, body: string): ServerError {
let detail = body.trim().slice(0, 300);
let refusalKind: string | undefined;
try {
const parsed = serverFailure.safeParse(JSON.parse(body));
if (parsed.success) {
if (parsed.data.message) detail = parsed.data.message;
refusalKind = parsed.data._tag;
}
} catch {
// Not JSON: the text itself is the detail.
}
const hint =
status === 401
? " (the server rejected the password: set CONVERGENCE_OPENCODE_SERVER_PASSWORD to the one it was started with)"
: "";
return new ServerError({
message: `opencode ${method} ${path} failed with ${status}: ${detail}${hint}`,
status,
refusalKind,
});
}
/// One request; the JSON answer (`null` for an empty one) is parsed with
/// `schema`. A grant refusal travels unchanged so the caller can say how
/// to allow it.
export const request = Effect.fn("OpenCodeV2.request")(function* <S extends z.ZodType>(
base: string,
auth: Auth | null,
method: string,
path: string,
schema: S,
{ query = {}, body, 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 exchange = Effect.gen(function* () {
const response = yield* net.fetch(`${base}${path}${queryString(query)}`, init);
const text = yield* Effect.tryPromise({
try: () => response.text(),
catch: (error) => new ServerError({ message: `${method} ${path}: ${errorMessage(error)}` }),
});
if (!response.ok) return yield* refusal(method, path, response.status, text);
const value = yield* Effect.try({
try: (): unknown => (text.trim() ? JSON.parse(text) : null),
catch: () =>
new ServerError({
message: `opencode ${method} ${path} did not answer with JSON (status ${response.status}: ${JSON.stringify(text.slice(0, 120))})`,
}),
});
const parsed = schema.safeParse(value);
if (!parsed.success)
return yield* new ServerError({
message: `opencode ${method} ${path} answered in an unexpected shape: ${parsed.error.message}`,
});
return parsed.data as z.infer<S>;
});
if (!timeout) return yield* exchange;
return yield* exchange.pipe(
Effect.timeoutOrElse({
duration: timeout,
orElse: () =>
Effect.fail(
new ServerError({
message: `opencode ${method} ${path} did not answer within ${Math.round(timeout / 1000)}s`,
}),
),
}),
);
});
// --- the local server ------------------------------------------------------------------------
/// The major version `program --version` reports, `null` when it does not
/// say. A missing program fails with `Unusable`.
const versionOf = Effect.fn("OpenCodeV2.versionOf")(function* (program: string) {
const result = yield* runProcess(program, ["--version"], { timeout: VERSION_TIMEOUT }).pipe(
Effect.scoped,
Effect.mapError((error) =>
/not on the PATH|No such file|not found|ENOENT/i.test(error.message)
? new Unusable({
message: `${program} was not found; install OpenCode 2 (npm i -g @opencode/cli) or choose its binary in Settings > OpenCode 2`,
})
: error,
),
);
return majorVersion(result.stdout);
});
/// An `opencode serve` this plugin started. Its scope owns the child and
/// the pipe drains; closing it stops the server.
export class LocalServer {
readonly child: ChildProcess;
readonly scope: Scope.Closeable;
base = "";
version = "";
log: string[] = [];
exited = false;
constructor(child: ChildProcess, scope: Scope.Closeable) {
this.child = child;
this.scope = scope;
void Promise.resolve(child.exited).then(() => {
this.exited = true;
});
}
/// Starts `program` (a 2.x OpenCode) with the password in `auth`, and
/// waits until it answers `GET /api/info`.
static start = Effect.fn("OpenCodeV2.start")(function* (program: string, auth: Auth) {
const major = yield* versionOf(program);
if (major !== null && major < 2)
return yield* new Unusable({
message:
`${program} is OpenCode ${major}.x; this agent speaks the OpenCode 2 API. Choose the 2.x binary in ` +
"Settings > OpenCode 2 (npm installs it in ~/.local/bin/opencode), or use the OpenCode agent for 1.x.",
});
const process = yield* Process;
const scope = Scope.makeUnsafe();
const child = yield* process
.spawn(program, ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
env: { OPENCODE_SERVER_USERNAME: auth.username, OPENCODE_SERVER_PASSWORD: auth.password },
})
.pipe(Effect.provideService(Scope.Scope, scope));
const server = new LocalServer(child, scope);
const started = yield* server.listen().pipe(
Effect.flatMap((base) =>
request(base, auth, "GET", "/api/info", api.info, { timeout: INFO_TIMEOUT }).pipe(
Effect.map((info) => {
server.base = base;
server.version = info.version;
return server;
}),
),
),
Effect.exit,
);
if (Exit.isSuccess(started)) return started.value;
yield* server.stop();
return yield* Effect.failCause(started.cause);
});
/// Reads stdout until the listen address appears; both pipes are then
/// drained for as long as the child runs, so it never blocks on a full
/// one.
listen = Effect.fn("OpenCodeV2.listen")(function* (this: LocalServer) {
const found = yield* Deferred.make<string, ServerError>();
const record = (line: string) => {
if (this.log.length === LOG_TAIL) this.log.shift();
this.log.push(line.slice(0, 1000));
};
const drain = async (pipe: ChildProcess["stdout"], watch: boolean) => {
try {
for await (const line of lines(pipe)) {
record(line);
const url = watch ? parseListenUrl(line) : null;
if (url) Deferred.doneUnsafe(found, Effect.succeed(url));
}
} catch {
// A broken pipe only loses the explanation.
}
if (watch) {
await this.child.exited.catch(() => null);
Deferred.doneUnsafe(
found,
Effect.fail(
new ServerError({ message: `opencode serve exited before it reported an address${this.logTail()}` }),
),
);
}
};
yield* Effect.forkIn(
Effect.promise(() => drain(this.child.stderr, false)),
this.scope,
);
yield* Effect.forkIn(
Effect.promise(() => drain(this.child.stdout, true)),
this.scope,
);
return yield* Deferred.await(found).pipe(
Effect.timeoutOrElse({
duration: START_TIMEOUT,
orElse: () =>
Effect.fail(
new ServerError({
message: `opencode serve did not report an address within ${START_TIMEOUT / 1000}s${this.logTail()}`,
}),
),
}),
);
});
/// 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 child (the spawn finalizer kills its process group).
stop = Effect.fn("OpenCodeV2.stop")(function* (this: LocalServer) {
yield* Scope.close(this.scope, Exit.void);
});
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 45.1 KB | 4 permissions | Listed |
No comments yet.