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