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

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${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.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

cli.ts8.7 KB
// The `opencode` command line, asked when the server cannot answer (the
// Rust `cli.rs`).
//
// A second **source** for the same live data, never a substitute for it:
// every model, mode and skill below is read out of the CLI's own output.
// When the CLI fails too, the failure is reported rather than papered
// over with a catalog nobody installed.
import * as Effect from "effect/Effect";
import * as Result from "effect/Result";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import { isRefusal } from "./client.ts";
import { obj } from "./map.ts";

export interface AgentEntry {
  name: string;
  description: string | null;
  mode: string;
  hidden: boolean;
}

export interface ProviderCatalog {
  providers: ProviderEntry[];
  default: Record<string, unknown>;
}

export interface Inventory {
  providers: ProviderCatalog;
  agents: AgentEntry[];
}

export interface SkillEntry {
  name: string;
  description: string;
  location: string;
}

/// How long one command may run: `models --verbose` prints the whole
/// catalog, which is slow on a first run that has to fetch it.
const COMMAND_TIMEOUT = 90_000;
/// Wait before the single retry. Every command opens the same SQLite
/// database, so one that lost the lock usually wins it a moment later.
const RETRY_DELAY = 1_000;
/// OpenCode is built with Bun, which flushes one 64 KiB buffer to a pipe
/// and then stops when the program exits: `debug skill` arrives cut at
/// exactly 65536 bytes. Output of a whole multiple of this size that does
/// not parse was cut.
const PIPE_CHUNK = 65_536;

/// The model catalog and the agents of `workspace`, as
/// `{ providers: { providers, default }, agents }`. The commands run one
/// at a time on purpose: they share one SQLite database and fail with a
/// lock error when they overlap.
export const inventory = Effect.fn("OpenCode.inventory")(function* (binary: string, workspace: string) {
  const read = yield* command(binary, workspace, ["models", "--verbose"]).pipe(Effect.result);
  if (Result.isFailure(read))
    return yield* Effect.fail(new Error(`reading the opencode model catalog: ${errorMessage(read.failure)}`));
  const models: string = read.success;
  const providers = parseModels(models);
  if (!providers.providers.length) {
    if (byteLength(models) % PIPE_CHUNK === 0)
      return yield* Effect.fail(new Error(cutMessage(["models", "--verbose"])));
    return yield* Effect.fail(
      new Error("`opencode models --verbose` listed no models; run `opencode auth login` first"),
    );
  }
  // Modes are a nicety next to the catalog, so a workspace whose agents
  // cannot be read still gets its models.
  let agents: AgentEntry[] = [];
  const listed = yield* command(binary, workspace, ["agent", "list"]).pipe(Effect.result);
  if (Result.isFailure(listed)) {
    console.warn(`opencode: could not read the agents from the command line: ${errorMessage(listed.failure)}`);
  } else {
    agents = parseAgents(listed.success);
  }
  return { providers, agents };
});

/// The skills of `workspace`, as `[{ name, description, location }]`.
export const skills = Effect.fn("OpenCode.skills")(function* (binary: string, workspace: string) {
  return parseSkills(yield* command(binary, workspace, ["debug", "skill"]));
});

/// The entries of `opencode debug skill`, which prints the same fields as
/// `GET /skill` plus the body, which is dropped.
export function parseSkills(text: unknown): SkillEntry[] {
  let parsed: unknown;
  try {
    parsed = JSON.parse(String(text));
  } catch (error) {
    if (byteLength(text) % PIPE_CHUNK === 0) throw new Error(cutMessage(["debug", "skill"]));
    throw new Error(`decoding the output of \`opencode debug skill\`: ${errorMessage(error)}`);
  }
  if (!Array.isArray(parsed)) throw new Error("`opencode debug skill` did not print a list");
  return parsed
    .filter((entry) => typeof (entry as { name?: unknown })?.name === "string")
    .map((entry) => {
      const record = entry as { name: string; description?: unknown; location?: unknown };
      return {
        name: record.name,
        description: typeof record.description === "string" ? record.description : "",
        location: typeof record.location === "string" ? record.location : "",
      };
    });
}

function cutMessage(args: string[]): string {
  return (
    `the output of \`opencode ${args.join(" ")}\` was cut at ${PIPE_CHUNK} bytes (opencode stops writing to a pipe ` +
    "after one buffer), so it cannot be read"
  );
}

function byteLength(text: unknown): number {
  return new TextEncoder().encode(String(text ?? "")).length;
}

/// Runs one command in `workspace`, retrying once. The retry does not
/// depend on the error text: the lock shows up as a non-zero exit with a
/// message that changes between versions.
const command = Effect.fn("OpenCode.command")(function* (binary: string, workspace: string, args: string[]) {
  const first = yield* attempt(binary, workspace, args).pipe(Effect.result);
  if (Result.isSuccess(first)) return first.success;
  if (isRefusal(first.failure)) return yield* Effect.fail(first.failure);
  yield* Effect.sleep(RETRY_DELAY);
  return yield* attempt(binary, workspace, args);
});

const attempt = Effect.fn("OpenCode.attempt")(function* (binary: string, workspace: string, args: string[]) {
  const described = `\`opencode ${args.join(" ")}\``;
  const result = yield* runProcess(binary, args, { cwd: workspace || undefined, timeout: COMMAND_TIMEOUT }).pipe(
    Effect.scoped,
  );
  if (result.timedOut)
    return yield* Effect.fail(new Error(`${described} did not finish within ${COMMAND_TIMEOUT / 1000}s`));
  if (result.code !== 0)
    return yield* Effect.fail(
      new Error(`${described} failed with ${result.code ?? result.signal}: ${result.stderr.trim()}`),
    );
  // A command that lost the SQLite lock can still exit 0 and print nothing,
  // which the retry is for. None of these has an empty answer when it
  // worked.
  if (!result.stdout.trim()) return yield* Effect.fail(new Error(`${described} printed nothing`));
  return result.stdout;
});

/// The `<provider>/<model>` header of a model block. A body line is
/// rejected before the shape is tested: a model id may contain a slash
/// (`openrouter/qwen/qwen3-coder`), and so may the JSON describing it.
function modelSlug(line: string): string | null {
  if (/^\s/.test(line) || /^\s*[{}[\]]/.test(line)) return null;
  const slug = line.trimEnd();
  if (!slug || slug.split(/\s+/).length !== 1) return null;
  const at = slug.indexOf("/");
  if (at <= 0 || at === slug.length - 1) return null;
  return slug;
}

interface ProviderEntry {
  id: string;
  name: string;
  models: Record<string, unknown>;
}

/// Parses `opencode models --verbose`: a `<provider>/<model>` line
/// followed by that model's JSON, repeated. The CLI reports no provider
/// display name, so the id stands in for it. Answers the shape of
/// `GET /config/providers`, with no defaults.
export function parseModels(text: unknown): ProviderCatalog {
  const providers: ProviderEntry[] = [];
  let slug: string | null = null;
  let body: string[] = [];
  const flush = (): void => {
    if (!slug) {
      body = [];
      return;
    }
    const current = slug;
    const providerId = current.slice(0, current.indexOf("/"));
    const json = body.join("\n").trim();
    slug = null;
    body = [];
    let model: unknown;
    try {
      model = JSON.parse(json);
    } catch {
      return;
    }
    const id = obj(model)["id"];
    if (typeof id !== "string") return;
    let provider = providers.find((entry) => entry.id === providerId);
    if (!provider) {
      provider = { id: providerId, name: providerId, models: {} };
      providers.push(provider);
    }
    provider.models[id] = model;
  };
  for (const line of String(text ?? "").split(/\r?\n/)) {
    const found = modelSlug(line);
    if (found) {
      flush();
      slug = found;
    } else if (slug) {
      body.push(line);
    }
  }
  flush();
  return { providers, default: {} };
}

/// Parses `opencode agent list`: a `<name> (<mode>)` line followed by that
/// agent's permission rules, repeated. The CLI does not say which agents
/// are hidden, so none are filtered by name; the mode is the only signal.
export function parseAgents(text: unknown): AgentEntry[] {
  const agents: AgentEntry[] = [];
  for (const line of String(text ?? "").split(/\r?\n/)) {
    if (/^\s/.test(line)) continue;
    const trimmed = line.trimEnd();
    if (!trimmed.endsWith(")")) continue;
    const open = trimmed.lastIndexOf("(");
    if (open < 0) continue;
    const name = trimmed.slice(0, open).trimEnd();
    const mode = trimmed.slice(open + 1, -1);
    if (!name || !mode || /\s/.test(mode)) continue;
    agents.push({ name, description: null, mode, hidden: false });
  }
  return agents;
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.