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

map.ts12.2 KB
// OpenCode 2 shapes to Convergence shapes: tool rows, approval and question
// cards, usage, and the transcript of a stored session. Pure functions; the
// agent keeps the state that ties live events together.
import * as z from "zod";
import type {
  ApprovalOption,
  ContentBlock,
  QuestionField,
  QuestionFieldKind,
  QuestionRequest,
  TaskInfo,
  ToolCall,
  ToolContent,
  ToolKind,
  TranscriptItem,
  Usage,
} from "convergence/protocol";
import { MCP_SERVER } from "../sdk/mcp.ts";
import * as api from "./api.ts";
import type {
  AssistantContent,
  AssistantMessage,
  Form,
  FormField,
  Message,
  PermissionRequest,
  Tokens,
  UserMessage,
} from "./api.ts";

// --- ids ----------------------------------------------------------------------------

/// The host item of one text or reasoning block: OpenCode numbers the
/// blocks of each kind in an assistant message from 0.
export const blockItemId = (messageId: string, kind: "text" | "reasoning", ordinal: number) =>
  `${messageId}/${kind}/${ordinal}`;

/// The host item of a tool call. A call id alone is not unique (a subagent
/// session numbers its calls from 0 again), the message id with it is.
export const toolItemId = (messageId: string, callId: string) => `${messageId}/${callId}`;

export const isoTime = (millis: number) => new Date(millis).toISOString();

// --- tools ---------------------------------------------------------------------------

/// The row kind of a tool, from its structured name only.
export function toolKind(name: string): ToolKind {
  switch (name) {
    case "read":
      return "read";
    case "edit":
    case "write":
    case "patch":
    case "apply_patch":
    case "multiedit":
      return "edit";
    case "shell":
    case "bash":
      return "execute";
    case "grep":
    case "glob":
    case "list":
    case "codesearch":
      return "search";
    case "webfetch":
    case "websearch":
      return "fetch";
    case "subagent":
    case "task":
      return "task";
    case "todowrite":
    case "todoread":
      return "think";
    default:
      return "other";
  }
}

const textField = (input: Record<string, unknown>, ...keys: string[]): string | null => {
  for (const key of keys) {
    const value = input[key];
    if (typeof value === "string" && value) return value;
  }
  return null;
};

/// The row title: what the call works on, else the tool's name.
export function toolTitle(name: string, input: Record<string, unknown>): string {
  switch (toolKind(name)) {
    case "execute":
      return textField(input, "description", "command") ?? name;
    case "read":
    case "edit":
      return textField(input, "path", "filePath", "file") ?? name;
    case "search":
      return textField(input, "pattern", "query", "path") ?? name;
    case "fetch":
      return textField(input, "url", "query") ?? name;
    case "task":
      return textField(input, "description", "prompt") ?? name;
    default:
      // A plugin tool reaches OpenCode as `convergence_<tool>`; the row
      // shows the tool's own name.
      return name.startsWith(`${MCP_SERVER}_`) ? name.slice(MCP_SERVER.length + 1) : name;
  }
}

/// The file a call touches, for the row's location.
export function toolLocations(name: string, input: Record<string, unknown>): { path: string }[] {
  const kind = toolKind(name);
  if (kind !== "read" && kind !== "edit") return [];
  const path = textField(input, "path", "filePath", "file");
  return path ? [{ path }] : [];
}

const textContent = z.looseObject({ type: z.literal("text"), text: z.string() });
const changedFiles = z.array(z.looseObject({ file: z.string(), patch: z.string() }));

/// The text of a tool result's content entries, joined.
export function resultText(content: readonly unknown[]): string {
  return content
    .flatMap((entry) => {
      const parsed = textContent.safeParse(entry);
      return parsed.success ? [parsed.data.text] : [];
    })
    .join("\n");
}

/// The content of a finished call: the diffs of an edit, the output of a
/// command, else its text.
export function toolContent(
  name: string,
  input: Record<string, unknown>,
  content: readonly unknown[],
  metadata: Record<string, unknown> = {},
): ToolContent[] {
  const text = resultText(content);
  const files = changedFiles.safeParse(metadata["files"]);
  if (files.success && files.data.length)
    return files.data.map((file) => ({ type: "diff", path: file.file, diff: file.patch }));
  if (toolKind(name) === "execute") {
    const exit = metadata["exit"];
    return [
      {
        type: "terminal",
        command: textField(input, "command") ?? "",
        output: text,
        ...(typeof exit === "number" ? { exitCode: exit } : {}),
      },
    ];
  }
  return text ? [{ type: "text", text }] : [];
}

/// A call as a row, from a stored assistant message or the events so far.
export function toolCall(messageId: string, entry: Extract<AssistantContent, { type: "tool" }>): ToolCall {
  const state = entry.state;
  const input = state.status === "streaming" ? {} : state.input;
  const call: ToolCall = {
    id: toolItemId(messageId, entry.id),
    name: entry.name,
    kind: toolKind(entry.name),
    title: toolTitle(entry.name, input),
    input,
    locations: toolLocations(entry.name, input),
  };
  switch (state.status) {
    case "streaming":
      return { ...call, status: "pending" };
    case "running":
      return { ...call, status: "running" };
    case "completed":
      return { ...call, status: "completed", content: toolContent(entry.name, input, state.content, state.metadata) };
    case "error":
      return {
        ...call,
        status: "failed",
        content: [{ type: "text", text: resultText(state.content ?? []) || state.error.message }],
      };
  }
}

/// The result of a subagent call without its wrapper:
/// `<subagent sessionID=".." state="completed">TEXT</subagent>`.
export function subagentResult(text: string): string {
  const match = /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>\s*$/.exec(text.trim());
  return match?.[1] ?? text;
}

// --- usage ------------------------------------------------------------------------------

/// What a step's tokens say about the context: everything the model read
/// and wrote in its last request.
export function usage(tokens: Tokens, cost: number, contextWindow: number | null): Usage {
  return {
    usedTokens: tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning,
    inputTokens: tokens.input,
    cachedInputTokens: tokens.cache.read,
    outputTokens: tokens.output,
    reasoningTokens: tokens.reasoning,
    costUsd: cost,
    ...(contextWindow ? { contextWindow } : {}),
  };
}

// --- approvals and questions ----------------------------------------------------------------

/// The choices of an approval card: OpenCode's own replies. "Always" is
/// offered only when the request names what it would save.
export function approvalOptions(request: PermissionRequest): ApprovalOption[] {
  const options: ApprovalOption[] = [{ id: "once", name: "Allow once", kind: "allow_once" }];
  if (request.save?.length) options.push({ id: "always", name: "Always allow", kind: "allow_always" });
  options.push({ id: "reject", name: "Reject", kind: "reject_once" });
  return options;
}

function fieldKind(field: FormField): QuestionFieldKind {
  if (field.type === "boolean") return "boolean";
  if (field.type === "multiselect") return "multi_select";
  return field.options?.length ? "select" : "text";
}

/// A form as a question card. Hidden fields are left out; `external` and
/// numeric fields are asked as text and converted back on reply.
export function formQuestion(form: Form): QuestionRequest {
  const fields: QuestionField[] = form.fields
    .filter((field) => !field.hidden)
    .map((field) => ({
      id: field.key,
      label: field.title ?? field.description ?? field.key,
      ...(field.title && field.description ? { description: field.description } : {}),
      kind: fieldKind(field),
      ...(field.options?.length ? { options: field.options } : {}),
      ...(field.custom ? { allowOther: true } : {}),
      ...(field.required !== undefined ? { required: field.required } : {}),
    }));
  return { id: form.id, ...(form.title ? { message: form.title } : {}), fields };
}

const stringList = z.array(z.string());

/// The answer of a form, from the values the user gave, in the type each
/// field asks for. An empty value is left out: the server applies the
/// field's default or reports a missing required one.
export function formAnswer(
  form: Form,
  values: Record<string, unknown>,
): Record<string, string | number | boolean | string[]> {
  const answer: Record<string, string | number | boolean | string[]> = {};
  for (const field of form.fields) {
    const value = values[field.key];
    if (value === undefined || value === null || value === "") continue;
    const list = stringList.safeParse(value);
    switch (field.type) {
      case "boolean":
        answer[field.key] = value === true || value === "true";
        break;
      case "number":
      case "integer": {
        const number = Number(value);
        if (Number.isFinite(number)) answer[field.key] = field.type === "integer" ? Math.trunc(number) : number;
        break;
      }
      case "multiselect":
        answer[field.key] = list.success ? list.data : [String(value)];
        break;
      default:
        answer[field.key] = list.success ? list.data.join(", ") : String(value);
        break;
    }
  }
  return answer;
}

// --- history ---------------------------------------------------------------------------------

/// What `read_session` needs to place a subagent: its task and transcript.
export interface Subagent {
  task: TaskInfo;
  items: TranscriptItem[];
}

const userBlocks = (message: UserMessage): ContentBlock[] => {
  const blocks: ContentBlock[] = [];
  if (message.text) blocks.push({ type: "text", text: message.text });
  for (const file of message.files ?? []) {
    // A mention is already in the text; an inline image has no path to show.
    if (file.source.type === "uri" && file.source.uri.startsWith("file://") && !message.text.includes("@"))
      blocks.push({ type: "file_ref", path: decodeURIComponent(file.source.uri.slice("file://".length)) });
  }
  return blocks;
};

function assistantItems(
  message: AssistantMessage,
  createdAt: string,
  subagent: (messageId: string, callId: string) => Subagent | null,
): TranscriptItem[] {
  const items: TranscriptItem[] = [];
  const ordinals = { text: 0, reasoning: 0 };
  for (const entry of message.content) {
    if (entry.type === "tool") {
      items.push({ id: toolItemId(message.id, entry.id), createdAt, role: "tool", call: toolCall(message.id, entry) });
      const child = toolKind(entry.name) === "task" ? subagent(message.id, entry.id) : null;
      if (child) items.push({ id: child.task.id, createdAt, role: "task", task: child.task, items: child.items });
      continue;
    }
    const id = blockItemId(message.id, entry.type, ordinals[entry.type]++);
    if (entry.text)
      items.push({ id, createdAt, role: entry.type === "text" ? "assistant" : "reasoning", text: entry.text });
  }
  if (message.error) items.push({ id: `${message.id}/error`, createdAt, role: "notice", text: message.error.message });
  return items;
}

/// The transcript of a session's messages. `subagent(messageId, callId)`
/// gives the subagent a `subagent` call started, which is placed right
/// after the call's row.
export function transcript(
  messages: readonly Message[],
  subagent: (messageId: string, callId: string) => Subagent | null = () => null,
): TranscriptItem[] {
  const items: TranscriptItem[] = [];
  for (const message of messages) {
    const createdAt = isoTime(message.time.created);
    const user = api.userMessage.safeParse(message);
    if (user.success) {
      items.push({ id: user.data.id, createdAt, role: "user", blocks: userBlocks(user.data) });
      continue;
    }
    const assistant = api.assistantMessage.safeParse(message);
    if (assistant.success) {
      items.push(...assistantItems(assistant.data, createdAt, subagent));
      continue;
    }
    const compaction = api.compactionMessage.safeParse(message);
    if (compaction.success) {
      const summary = compaction.data.summary;
      items.push({ id: compaction.data.id, createdAt, role: "compaction", ...(summary ? { summary } : {}) });
    }
  }
  return items;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.