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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 45.1 KB | 4 permissions | Listed |
No comments yet.