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
cli.ts8.7 KB
// The `opencode` command line, asked when the server cannot answer (the
// Rust `cli.rs`).
//
// A second **source** for the same live data, never a substitute for it:
// every model, mode and skill below is read out of the CLI's own output.
// When the CLI fails too, the failure is reported rather than papered
// over with a catalog nobody installed.
import * as Effect from "effect/Effect";
import * as Result from "effect/Result";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import { isRefusal } from "./client.ts";
import { obj } from "./map.ts";
export interface AgentEntry {
name: string;
description: string | null;
mode: string;
hidden: boolean;
}
export interface ProviderCatalog {
providers: ProviderEntry[];
default: Record<string, unknown>;
}
export interface Inventory {
providers: ProviderCatalog;
agents: AgentEntry[];
}
export interface SkillEntry {
name: string;
description: string;
location: string;
}
/// How long one command may run: `models --verbose` prints the whole
/// catalog, which is slow on a first run that has to fetch it.
const COMMAND_TIMEOUT = 90_000;
/// Wait before the single retry. Every command opens the same SQLite
/// database, so one that lost the lock usually wins it a moment later.
const RETRY_DELAY = 1_000;
/// OpenCode is built with Bun, which flushes one 64 KiB buffer to a pipe
/// and then stops when the program exits: `debug skill` arrives cut at
/// exactly 65536 bytes. Output of a whole multiple of this size that does
/// not parse was cut.
const PIPE_CHUNK = 65_536;
/// The model catalog and the agents of `workspace`, as
/// `{ providers: { providers, default }, agents }`. The commands run one
/// at a time on purpose: they share one SQLite database and fail with a
/// lock error when they overlap.
export const inventory = Effect.fn("OpenCode.inventory")(function* (binary: string, workspace: string) {
const read = yield* command(binary, workspace, ["models", "--verbose"]).pipe(Effect.result);
if (Result.isFailure(read))
return yield* Effect.fail(new Error(`reading the opencode model catalog: ${errorMessage(read.failure)}`));
const models: string = read.success;
const providers = parseModels(models);
if (!providers.providers.length) {
if (byteLength(models) % PIPE_CHUNK === 0)
return yield* Effect.fail(new Error(cutMessage(["models", "--verbose"])));
return yield* Effect.fail(
new Error("`opencode models --verbose` listed no models; run `opencode auth login` first"),
);
}
// Modes are a nicety next to the catalog, so a workspace whose agents
// cannot be read still gets its models.
let agents: AgentEntry[] = [];
const listed = yield* command(binary, workspace, ["agent", "list"]).pipe(Effect.result);
if (Result.isFailure(listed)) {
console.warn(`opencode: could not read the agents from the command line: ${errorMessage(listed.failure)}`);
} else {
agents = parseAgents(listed.success);
}
return { providers, agents };
});
/// The skills of `workspace`, as `[{ name, description, location }]`.
export const skills = Effect.fn("OpenCode.skills")(function* (binary: string, workspace: string) {
return parseSkills(yield* command(binary, workspace, ["debug", "skill"]));
});
/// The entries of `opencode debug skill`, which prints the same fields as
/// `GET /skill` plus the body, which is dropped.
export function parseSkills(text: unknown): SkillEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(String(text));
} catch (error) {
if (byteLength(text) % PIPE_CHUNK === 0) throw new Error(cutMessage(["debug", "skill"]));
throw new Error(`decoding the output of \`opencode debug skill\`: ${errorMessage(error)}`);
}
if (!Array.isArray(parsed)) throw new Error("`opencode debug skill` did not print a list");
return parsed
.filter((entry) => typeof (entry as { name?: unknown })?.name === "string")
.map((entry) => {
const record = entry as { name: string; description?: unknown; location?: unknown };
return {
name: record.name,
description: typeof record.description === "string" ? record.description : "",
location: typeof record.location === "string" ? record.location : "",
};
});
}
function cutMessage(args: string[]): string {
return (
`the output of \`opencode ${args.join(" ")}\` was cut at ${PIPE_CHUNK} bytes (opencode stops writing to a pipe ` +
"after one buffer), so it cannot be read"
);
}
function byteLength(text: unknown): number {
return new TextEncoder().encode(String(text ?? "")).length;
}
/// Runs one command in `workspace`, retrying once. The retry does not
/// depend on the error text: the lock shows up as a non-zero exit with a
/// message that changes between versions.
const command = Effect.fn("OpenCode.command")(function* (binary: string, workspace: string, args: string[]) {
const first = yield* attempt(binary, workspace, args).pipe(Effect.result);
if (Result.isSuccess(first)) return first.success;
if (isRefusal(first.failure)) return yield* Effect.fail(first.failure);
yield* Effect.sleep(RETRY_DELAY);
return yield* attempt(binary, workspace, args);
});
const attempt = Effect.fn("OpenCode.attempt")(function* (binary: string, workspace: string, args: string[]) {
const described = `\`opencode ${args.join(" ")}\``;
const result = yield* runProcess(binary, args, { cwd: workspace || undefined, timeout: COMMAND_TIMEOUT }).pipe(
Effect.scoped,
);
if (result.timedOut)
return yield* Effect.fail(new Error(`${described} did not finish within ${COMMAND_TIMEOUT / 1000}s`));
if (result.code !== 0)
return yield* Effect.fail(
new Error(`${described} failed with ${result.code ?? result.signal}: ${result.stderr.trim()}`),
);
// A command that lost the SQLite lock can still exit 0 and print nothing,
// which the retry is for. None of these has an empty answer when it
// worked.
if (!result.stdout.trim()) return yield* Effect.fail(new Error(`${described} printed nothing`));
return result.stdout;
});
/// The `<provider>/<model>` header of a model block. A body line is
/// rejected before the shape is tested: a model id may contain a slash
/// (`openrouter/qwen/qwen3-coder`), and so may the JSON describing it.
function modelSlug(line: string): string | null {
if (/^\s/.test(line) || /^\s*[{}[\]]/.test(line)) return null;
const slug = line.trimEnd();
if (!slug || slug.split(/\s+/).length !== 1) return null;
const at = slug.indexOf("/");
if (at <= 0 || at === slug.length - 1) return null;
return slug;
}
interface ProviderEntry {
id: string;
name: string;
models: Record<string, unknown>;
}
/// Parses `opencode models --verbose`: a `<provider>/<model>` line
/// followed by that model's JSON, repeated. The CLI reports no provider
/// display name, so the id stands in for it. Answers the shape of
/// `GET /config/providers`, with no defaults.
export function parseModels(text: unknown): ProviderCatalog {
const providers: ProviderEntry[] = [];
let slug: string | null = null;
let body: string[] = [];
const flush = (): void => {
if (!slug) {
body = [];
return;
}
const current = slug;
const providerId = current.slice(0, current.indexOf("/"));
const json = body.join("\n").trim();
slug = null;
body = [];
let model: unknown;
try {
model = JSON.parse(json);
} catch {
return;
}
const id = obj(model)["id"];
if (typeof id !== "string") return;
let provider = providers.find((entry) => entry.id === providerId);
if (!provider) {
provider = { id: providerId, name: providerId, models: {} };
providers.push(provider);
}
provider.models[id] = model;
};
for (const line of String(text ?? "").split(/\r?\n/)) {
const found = modelSlug(line);
if (found) {
flush();
slug = found;
} else if (slug) {
body.push(line);
}
}
flush();
return { providers, default: {} };
}
/// Parses `opencode agent list`: a `<name> (<mode>)` line followed by that
/// agent's permission rules, repeated. The CLI does not say which agents
/// are hidden, so none are filtered by name; the mode is the only signal.
export function parseAgents(text: unknown): AgentEntry[] {
const agents: AgentEntry[] = [];
for (const line of String(text ?? "").split(/\r?\n/)) {
if (/^\s/.test(line)) continue;
const trimmed = line.trimEnd();
if (!trimmed.endsWith(")")) continue;
const open = trimmed.lastIndexOf("(");
if (open < 0) continue;
const name = trimmed.slice(0, open).trimEnd();
const mode = trimmed.slice(open + 1, -1);
if (!name || !mode || /\s/.test(mode)) continue;
agents.push({ name, description: null, mode, hidden: false });
}
return agents;
}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.