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
api.ts13 KB
// The shapes of the OpenCode 2 HTTP API this plugin reads, as Zod schemas.
// They follow the server's own OpenAPI document (`GET /openapi.json`,
// opencode 2.0.16) and live captures of `GET /api/event`; see NOTES.md.
// Objects are loose: a newer server may add fields, and none of them is
// needed to drive a session.
import * as z from "zod";
export const modelRef = z.looseObject({
id: z.string(),
providerID: z.string(),
variant: z.string().optional(),
});
export type ModelRef = z.infer<typeof modelRef>;
export const tokens = z.looseObject({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.looseObject({ read: z.number(), write: z.number() }),
});
export type Tokens = z.infer<typeof tokens>;
export const structuredError = z.looseObject({ type: z.string().optional(), message: z.string() });
// --- catalog ---------------------------------------------------------------------
export const provider = z.looseObject({ id: z.string(), name: z.string() });
export type Provider = z.infer<typeof provider>;
export const model = z.looseObject({
id: z.string(),
providerID: z.string(),
name: z.string(),
enabled: z.boolean().optional(),
status: z.string().optional(),
capabilities: z.looseObject({ input: z.array(z.string()).optional() }).optional(),
limit: z.looseObject({ context: z.number().optional() }).optional(),
variants: z.array(z.looseObject({ id: z.string() })).optional(),
});
export type Model = z.infer<typeof model>;
export const agent = z.looseObject({
id: z.string(),
name: z.string(),
description: z.string().optional(),
mode: z.string(),
hidden: z.boolean().optional(),
});
export type Agent = z.infer<typeof agent>;
export const command = z.looseObject({ name: z.string(), description: z.string().optional() });
export const skill = z.looseObject({ id: z.string(), name: z.string(), description: z.string().optional() });
/// `{ location, data }`, the envelope of every catalog answer.
export const listed = <T extends z.ZodType>(item: T) => z.looseObject({ data: z.array(item) });
export const single = <T extends z.ZodType>(item: T) => z.looseObject({ data: item });
export const info = z.looseObject({ version: z.string() });
export const mcpServers = z.looseObject({
data: z.array(
z.looseObject({
name: z.string(),
status: z.looseObject({ status: z.string(), error: z.string().optional() }),
}),
),
});
// --- sessions ----------------------------------------------------------------------
export const session = z.looseObject({
id: z.string(),
parentID: z.string().nullish(),
title: z.string().optional(),
agent: z.string().optional(),
model: modelRef.optional(),
time: z.looseObject({ created: z.number(), updated: z.number() }),
});
export type Session = z.infer<typeof session>;
export const sessionPage = z.looseObject({
data: z.array(session),
cursor: z.looseObject({ next: z.string().nullish() }).optional(),
});
export const active = z.looseObject({ data: z.record(z.string(), z.unknown()) });
const toolState = z.discriminatedUnion("status", [
z.looseObject({ status: z.literal("streaming"), input: z.string() }),
z.looseObject({
status: z.literal("running"),
input: z.record(z.string(), z.unknown()),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
z.looseObject({
status: z.literal("completed"),
input: z.record(z.string(), z.unknown()),
content: z.array(z.unknown()),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
z.looseObject({
status: z.literal("error"),
input: z.record(z.string(), z.unknown()),
error: structuredError,
content: z.array(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
]);
export type ToolState = z.infer<typeof toolState>;
export const assistantContent = z.discriminatedUnion("type", [
z.looseObject({ type: z.literal("text"), text: z.string() }),
z.looseObject({ type: z.literal("reasoning"), text: z.string() }),
z.looseObject({ type: z.literal("tool"), id: z.string(), name: z.string(), state: toolState }),
]);
export type AssistantContent = z.infer<typeof assistantContent>;
const fileAttachment = z.looseObject({
mime: z.string(),
name: z.string().optional(),
source: z.discriminatedUnion("type", [
z.looseObject({ type: z.literal("inline") }),
z.looseObject({ type: z.literal("uri"), uri: z.string() }),
]),
});
const time = z.looseObject({ created: z.number() });
/// One entry of `GET /api/session/{id}/message`, of any kind. The kinds the
/// transcript shows are read with the schemas below; the others (agent and
/// model switches, idle markers, ...) are skipped.
export const message = z.looseObject({ type: z.string(), id: z.string(), time });
export type Message = z.infer<typeof message>;
export const userMessage = z.looseObject({
type: z.literal("user"),
id: z.string(),
time,
text: z.string(),
files: z.array(fileAttachment).optional(),
});
export type UserMessage = z.infer<typeof userMessage>;
/// Installed 2.0.16 returns Session.Inbox.User; current upstream renamed
/// that durable row SessionPending.User. Neither shape proves promotion.
export const admittedInput = z.union([
z.looseObject({
id: z.string(),
sessionID: z.string(),
type: z.literal("user"),
time,
payload: z.record(z.string(), z.unknown()),
delivery: z.enum(["steer", "queue"]),
}),
z.looseObject({
id: z.string(),
sessionID: z.string(),
type: z.literal("user"),
timeCreated: z.number(),
admittedSeq: z.number(),
data: z.record(z.string(), z.unknown()),
delivery: z.enum(["steer", "queue"]),
}),
]);
export type AdmittedInput = z.infer<typeof admittedInput>;
export const assistantMessage = z.looseObject({
type: z.literal("assistant"),
id: z.string(),
time,
agent: z.string(),
model: modelRef,
content: z.array(assistantContent),
error: structuredError.optional(),
});
export type AssistantMessage = z.infer<typeof assistantMessage>;
export const compactionMessage = z.looseObject({
type: z.literal("compaction"),
id: z.string(),
time,
summary: z.string().optional(),
});
export const messagePage = z.looseObject({
data: z.array(message),
cursor: z.looseObject({ next: z.string().nullish() }).optional(),
});
// --- permissions and forms -------------------------------------------------------------
export const toolSource = z.looseObject({ type: z.literal("tool"), messageID: z.string(), id: z.string() });
export const permissionRequest = z.looseObject({
id: z.string(),
sessionID: z.string(),
action: z.string(),
resources: z.array(z.string()),
save: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
source: toolSource.optional(),
message: z.string().optional(),
});
export type PermissionRequest = z.infer<typeof permissionRequest>;
const formOption = z.looseObject({ value: z.string(), label: z.string(), description: z.string().optional() });
export const formField = z.looseObject({
key: z.string(),
type: z.string(),
title: z.string().optional(),
description: z.string().optional(),
required: z.boolean().optional(),
hidden: z.boolean().optional(),
options: z.array(formOption).optional(),
custom: z.boolean().optional(),
});
export type FormField = z.infer<typeof formField>;
export const form = z.looseObject({
id: z.string(),
sessionID: z.string(),
title: z.string(),
fields: z.array(formField),
metadata: z.looseObject({ tool: z.looseObject({ messageID: z.string(), id: z.string() }).optional() }).optional(),
});
export type Form = z.infer<typeof form>;
// --- the event stream -------------------------------------------------------------------
/// The envelope of every `GET /api/event` frame. `data` is read per type
/// by the mapper; the OpenAPI document leaves it opaque.
export const envelope = z.looseObject({
type: z.string(),
data: z.unknown(),
location: z.looseObject({ directory: z.string() }).optional(),
});
/// Events that say a catalog changed: the server builds its providers,
/// models and agents in the background after it starts (and again when a
/// plugin, a credential or the config changes), so a catalog read early is
/// incomplete until one of these arrives.
const CATALOG_EVENTS = new Set([
"provider.updated",
"model.updated",
"agent.updated",
"command.updated",
"skill.updated",
"credential.updated",
"credential.switched",
"integration.updated",
"plugin.updated",
]);
/// The folder whose catalog changed (`null`: every folder), when the frame
/// is a catalog event.
export function catalogChange(raw: unknown): { directory: string | null } | null {
const frame = envelope.safeParse(raw);
if (!frame.success || !CATALOG_EVENTS.has(frame.data.type)) return null;
return { directory: frame.data.location?.directory ?? null };
}
const inSession = { sessionID: z.string() };
const inMessage = { ...inSession, assistantMessageID: z.string() };
const toolEvent = { ...inMessage, id: z.string() };
const text = { ...inMessage, ordinal: z.number() };
/// The payload of each event type the plugin acts on; any other type is
/// ignored.
export const events = {
"session.created": z.looseObject({
...inSession,
parentID: z.string().optional(),
title: z.string().optional(),
agent: z.string().optional(),
model: modelRef.optional(),
}),
"session.renamed": z.looseObject({ ...inSession, title: z.string() }),
"session.execution.started": z.looseObject(inSession),
"session.execution.succeeded": z.looseObject(inSession),
"session.execution.failed": z.looseObject({ ...inSession, error: structuredError }),
"session.execution.interrupted": z.looseObject(inSession),
"session.inbox.delivered": z.looseObject({ ...inSession, inboxID: z.string() }),
"session.input.promoted": z.looseObject({ ...inSession, inputID: z.string() }),
"session.step.started": z.looseObject({ ...inMessage, agent: z.string().optional(), model: modelRef.optional() }),
"session.step.ended": z.looseObject({ ...inMessage, tokens: tokens.optional() }),
"session.step.failed": z.looseObject({ ...inMessage, error: structuredError }),
"session.usage.updated": z.looseObject({ ...inSession, cost: z.number(), tokens }),
"session.retry.scheduled": z.looseObject({ ...inMessage, attempt: z.number(), error: structuredError }),
"session.text.delta": z.looseObject({ ...text, delta: z.string() }),
"session.text.ended": z.looseObject({ ...text, text: z.string() }),
"session.reasoning.delta": z.looseObject({ ...text, delta: z.string() }),
"session.reasoning.ended": z.looseObject({ ...text, text: z.string() }),
"session.tool.input.started": z.looseObject({ ...toolEvent, name: z.string() }),
"session.tool.called": z.looseObject({ ...toolEvent, input: z.record(z.string(), z.unknown()) }),
"session.tool.progress": z.looseObject({ ...toolEvent, metadata: z.record(z.string(), z.unknown()) }),
"session.tool.success": z.looseObject({
...toolEvent,
content: z.array(z.unknown()),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
"session.tool.failed": z.looseObject({
...toolEvent,
error: structuredError,
content: z.array(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
"session.compaction.ended": z.looseObject({ ...inSession, text: z.string().optional() }),
"session.compaction.failed": z.looseObject({ ...inSession, error: structuredError }),
"permission.asked": permissionRequest,
"permission.replied": z.looseObject({ ...inSession, requestID: z.string() }),
"form.created": z.looseObject({ form }),
"form.replied": z.looseObject({ ...inSession, id: z.string() }),
"form.cancelled": z.looseObject({ ...inSession, id: z.string() }),
/// Input the server adds by itself; a background subagent's result
/// arrives this way (`metadata.source: "subagent"`).
"session.synthetic": z.looseObject({
...inSession,
text: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
} as const;
export type EventType = keyof typeof events;
export type EventData<T extends EventType> = z.infer<(typeof events)[T]>;
/// A parsed event of a type the plugin acts on.
export type ServerEvent = { [T in EventType]: { type: T; data: EventData<T> } }[EventType];
export function isEventType(type: string): type is EventType {
return Object.hasOwn(events, type);
}
/// The event in one frame, or `null` for a type the plugin ignores or a
/// payload that does not match its schema (logged: a changed payload is a
/// server change worth knowing about, not a reason to drop the stream).
export function parseEvent(raw: unknown): ServerEvent | null {
const frame = envelope.safeParse(raw);
if (!frame.success || !isEventType(frame.data.type)) return null;
const type = frame.data.type;
const data = events[type].safeParse(frame.data.data);
if (!data.success) {
console.warn(`opencode-v2: the ${type} event does not have the expected shape: ${data.error.message}`);
return null;
}
// The schema chosen by `type` produced `data`, so the pair is one member
// of the union; TypeScript cannot follow the correlation through the
// indexed lookup.
return { type, data: data.data } as ServerEvent;
}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.