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

  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode 2 agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode 2 server (`opencode serve`, or the binary you choose in Settings) and read its versionPrograms: opencode${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Read the user name and password of the OpenCode serverVariables: OPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

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

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <345.1 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.