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

prompt.ts3.9 KB
// What the plugin sends OpenCode for a prompt: one text with the file and
// skill attachments it mentions (`POST /api/session/{id}/prompt`), or the
// slash command the text invokes (`POST /api/session/{id}/command`).
import type { ContentBlock } from "convergence/protocol";

export interface Mention {
  start: number;
  end: number;
  text: string;
}

export interface FileAttachment {
  uri: string;
  name?: string;
  mention?: Mention;
}

export interface SkillAttachment {
  id: string;
  mention?: Mention;
}

export interface PromptBody {
  text: string;
  files?: FileAttachment[];
  skills?: SkillAttachment[];
}

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

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

/// An inline attachment as a `data:` URI. One the model would refuse is
/// reported here rather than in a turn that dies on the far side.
function inline(what: string, mimeType: string, data: string): FileAttachment {
  const size = base64Size(data);
  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`,
    );
  return { uri: `data:${mimeType};base64,${data}`, name: what };
}

/// `file:///a%20b/c.ts` for an absolute path.
export function fileUri(path: string): string {
  return `file://${path.split("/").map(encodeURIComponent).join("/")}`;
}

/// The prompt body for the host's blocks. A mention (`@path`, `$skill`)
/// stays in the text where the user wrote it, and its attachment names that
/// span, which is how OpenCode links the two.
export function promptBody(blocks: readonly ContentBlock[]): PromptBody {
  let text = "";
  const files: FileAttachment[] = [];
  const skills: SkillAttachment[] = [];
  const mention = (written: string): Mention => {
    const start = text.length;
    text += written;
    return { start, end: text.length, text: written };
  };
  for (const block of blocks) {
    switch (block.type) {
      case "text":
        text += block.text;
        break;
      case "image":
        files.push(inline("image", block.mimeType, block.data));
        break;
      case "audio":
        files.push(inline("audio", block.mimeType, block.data));
        break;
      // The server reads the file itself: this plugin has no grant to.
      case "file_ref":
        files.push({
          uri: fileUri(block.path),
          name: block.path.split("/").pop() || block.path,
          mention: mention(`@${block.path}`),
        });
        break;
      case "resource":
        text += `${block.path}:\n${block.text}`;
        break;
      case "skill": {
        skills.push({ id: block.name, mention: mention(`$${block.name}`) });
        if (block.input) text += ` ${block.input}`;
        break;
      }
      default:
        break;
    }
  }
  return { text, ...(files.length ? { files } : {}), ...(skills.length ? { skills } : {}) };
}

/// The command a prompt invokes, when its text starts with `/name` and
/// `name` is one the server lists: `{ name, text }` with the rest as the
/// command's input.
export function slashCommand(
  body: PromptBody,
  commands: readonly { name: string }[],
): { name: string; text: string } | null {
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(body.text);
  const name = match?.[1];
  if (!name || !commands.some((command) => command.name === name)) return null;
  return { name, text: match[2] ?? "" };
}

/// The OpenCode message id a host item becomes: the server accepts an id
/// that starts with `msg_`, so the host's own id needs no table.
export const messageIdFor = (hostItemId: string) => (hostItemId.startsWith("msg_") ? hostItemId : `msg_${hostItemId}`);

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.