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

prompt.ts7.1 KB
// What the plugin sends OpenCode for a prompt: the `parts` of
// `prompt_async`, the message id the host's item becomes, and the slash
// command a prompt may invoke. Pure functions of their input.
import * as z from "zod";
import { obj } from "./map.ts";

export interface PromptBlock {
  type?: unknown;
  text?: unknown;
  path?: unknown;
  mimeType?: unknown;
  data?: unknown;
  name?: unknown;
  input?: unknown;
}

const blockSchema = z.looseObject({
  type: z.unknown().optional(),
  text: z.unknown().optional(),
  path: z.unknown().optional(),
  mimeType: z.unknown().optional(),
  data: z.unknown().optional(),
  name: z.unknown().optional(),
  input: z.unknown().optional(),
});

/// A prompt block as the host sends it; anything else reads as empty.
function asBlock(raw: unknown): PromptBlock {
  const parsed = blockSchema.safeParse(raw);
  return parsed.success ? parsed.data : {};
}

export interface PromptPart {
  type: string;
  text?: string;
  mime?: string;
  filename?: string;
  url?: string;
}

export interface SlashInvocation {
  name: string;
  arguments: string;
}

/// The largest attachment a model behind OpenCode accepts as a file part.
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;

/// Whether OpenCode can hand an attachment of this type to a model.
/// Anything else (an archive, a binary, an image format the model APIs
/// reject) fails the turn before it starts, which reads to the user as
/// the prompt being broken rather than the file being unsupported.
export function isSupportedAttachment(mime: unknown): boolean {
  const type = String(mime ?? "")
    .trim()
    .toLowerCase();
  return (
    ["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"].includes(type) || type.startsWith("text/")
  );
}

/// The decoded size of base64 data, without decoding it.
export function base64Size(data: unknown): number {
  const text = String(data ?? "");
  let padding = 0;
  for (let i = text.length - 1; i >= 0 && text[i] === "=" && padding < 2; i -= 1) padding += 1;
  return Math.floor(text.length / 4) * 3 - padding;
}

function checkAttachment(what: string, mime: string, size: number): void {
  if (!isSupportedAttachment(mime))
    throw new Error(`opencode cannot attach ${what}: it accepts images, text and PDFs, not ${mime}`);
  if (size > MAX_ATTACHMENT_BYTES) {
    throw new Error(
      `opencode cannot attach ${what}: it is ${Math.floor(size / 1_000_000)} MB, over the ${MAX_ATTACHMENT_BYTES / 1_000_000} MB limit`,
    );
  }
}

/// The type of a mentioned file, by its extension.
export function mimeOf(path: unknown): string {
  const extension = String(path ?? "")
    .split(".")
    .pop()
    ?.toLowerCase();
  switch (extension) {
    case "png":
      return "image/png";
    case "jpg":
    case "jpeg":
      return "image/jpeg";
    case "gif":
      return "image/gif";
    case "webp":
      return "image/webp";
    case "pdf":
      return "application/pdf";
    default:
      return "text/plain";
  }
}

/// Turns prompt blocks into `prompt_async` parts. An attachment the server
/// would refuse is reported here instead of in a turn that dies on the far
/// side with an error nobody can trace back to the file.
export function promptParts(blocks: unknown): PromptPart[] {
  const parts: PromptPart[] = [];
  for (const raw of Array.isArray(blocks) ? blocks : []) {
    const block = asBlock(raw);
    switch (block.type) {
      case "text":
        parts.push({ type: "text", text: String(block.text ?? "") });
        break;
      case "image": {
        const mime = String(block.mimeType ?? "");
        checkAttachment("the image", mime, base64Size(block.data));
        parts.push({ type: "file", mime, filename: "image", url: `data:${mime};base64,${block.data ?? ""}` });
        break;
      }
      case "audio": {
        const mime = String(block.mimeType ?? "");
        checkAttachment("the audio", mime, base64Size(block.data));
        parts.push({ type: "file", mime, filename: "audio", url: `data:${mime};base64,${block.data ?? ""}` });
        break;
      }
      // The server reads the file and injects its content, which is what
      // an `@path` mention means in OpenCode. Its size is the server's to
      // check: this plugin has no grant to read the user's files.
      case "file_ref": {
        const path = String(block.path ?? "");
        const mime = mimeOf(path);
        checkAttachment(path, mime, 0);
        parts.push({ type: "file", mime, filename: path.split("/").pop() || path, url: `file://${path}` });
        break;
      }
      // OpenCode runs a skill through its command form.
      case "skill": {
        const input = String(block.input ?? "");
        const name = String(block.name);
        parts.push({ type: "text", text: input ? `/${name} ${input}` : `/${name}` });
        break;
      }
      case "resource":
        parts.push({ type: "text", text: `${block.path ?? ""}:\n${block.text ?? ""}` });
        break;
      default:
        break;
    }
  }
  return parts;
}

/// The prompt as one line of text, as the host's `PromptInput::plain_text`
/// writes it (`@path` for a mention, `$name` for a skill).
export function plainText(blocks: unknown): string {
  let out = "";
  for (const raw of Array.isArray(blocks) ? blocks : []) {
    const block = asBlock(raw);
    switch (block.type) {
      case "text":
        out += String(block.text ?? "");
        break;
      case "file_ref":
      case "resource":
        out += `@${String(block.path ?? "")}`;
        break;
      case "image":
        out += "[image]";
        break;
      case "audio":
        out += "[audio]";
        break;
      case "skill":
        out += `$${String(block.name ?? "")}${block.input ? ` ${String(block.input)}` : ""}`;
        break;
      default:
        break;
    }
  }
  return out;
}

/// The OpenCode message id a host item becomes. OpenCode accepts a
/// client-chosen `messageID` that starts with `msg`; the host's id goes
/// after that prefix, so a rollback by the host's id finds the message
/// with no table, across restarts.
export function messageIdFor(hostItemId: unknown): string {
  const id = String(hostItemId);
  return id.startsWith("msg") ? id : `msg_${id}`;
}

/// The command a prompt invokes, when its text starts with `/name` and
/// `name` is one of `commands` (`GET /command`): `{ name, arguments }`.
export function slashCommand(text: unknown, commands: unknown): SlashInvocation | null {
  if (typeof text !== "string" || !text.startsWith("/")) return null;
  const rest = text.slice(1);
  const space = rest.search(/\s/);
  const name = space < 0 ? rest : rest.slice(0, space);
  const args = space < 0 ? "" : rest.slice(space).trimStart();
  if (!name) return null;
  if (!(Array.isArray(commands) ? commands : []).some((command) => obj(command)["name"] === name)) return null;
  return { name, arguments: args };
}

/// Where a skill came from, which is all its location says.
export function skillSource(location: unknown, workspace: unknown): string {
  if (typeof location !== "string" || !location || location.startsWith("<")) return "built-in";
  return typeof workspace === "string" && workspace && location.startsWith(workspace) ? "project" : "user";
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.