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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.