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
agent.ts88.6 KB
// The OpenCode provider: one `opencode serve` per agent (or a server the
// user runs), one event stream per workspace folder, and the HTTP calls of
// the v1 session family (`/session`, `/session/{id}/prompt_async`,
// `/event`). See NOTES.md for the API facts behind every choice here.
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as z from "zod";
import { Host, Kernel, parse } from "convergence/effect";
import type { PluginServices, EffectAgent } from "convergence/effect";
import type {
AgentEventKind,
AgentInfo,
ApprovalRequest,
InputReceipt,
NoticeLevel,
QuestionRequest,
RunOutcome,
SessionSummary,
Skill,
SlashCommand,
TaskInfo,
TaskStatus,
TranscriptItem,
Usage,
} from "convergence/protocol";
import { newId, compact, outcome, permissionMode } from "../sdk/agent.ts";
import { MCP_SERVER } from "../sdk/mcp.ts";
import { parseSse } from "../sdk/sse.ts";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import * as cli from "./cli.ts";
import * as client from "./client.ts";
import { isRefusal } from "./client.ts";
import type { Auth } from "./client.ts";
import { ICON } from "./icon.ts";
import { agentId, command, displayName, serverUrl } from "./instances.ts";
import type { Instance } from "./instances.ts";
import * as maintenance from "./maintenance.ts";
import * as map from "./map.ts";
import { obj, arr } from "./map.ts";
import type { Mapped, TaskLink } from "./map.ts";
import type { SessionHistory } from "./map.ts";
import { OPTION_AGENT, OPTION_MODEL, OPTION_VARIANT, buildOptions, contextWindow, splitModel } from "./options.ts";
import type { ConfigOption } from "convergence/protocol";
import * as permission from "./permission.ts";
import { messageIdFor, plainText, promptParts, skillSource, slashCommand } from "./prompt.ts";
import type { SlashInvocation } from "./prompt.ts";
import { schemas } from "./types.ts";
import type { Event, PromptInput, QuestionInput, RunState, SessionParams, SessionState, StreamEntry } from "./types.ts";
export const FAMILY = "opencode";
/// How long `GET /skill` may take: skills are read while the composer is
/// open, so a server that stalls must not hold the picker.
const SKILLS_TIMEOUT = 20_000;
/// How long `session.summarize` may take: it runs a whole model turn.
const COMPACT_TIMEOUT = 600_000;
/// Longest subagent chain walked when reading history.
const MAX_SUBAGENT_DEPTH = 32;
/// Event stream reconnection: the first wait, doubling up to the most; a
/// connection that held this long starts the backoff again; a server the
/// user runs may stay unreachable this long before its runs fail.
const RECONNECT_MIN = 250;
const RECONNECT_MAX = 10_000;
const RECONNECT_RESET = 30_000;
const RECONNECT_GIVE_UP = 60_000;
const now = (): string => new Date().toISOString();
/// An error that says what was being done, keeping the trouble behind it
/// (a rejected password stays one when it is described).
interface Annotated extends Error {
trouble?: client.Trouble;
}
function annotate(what: string, error: unknown): Annotated {
const out: Annotated = new Error(`${what}: ${errorMessage(error)}`);
const trouble = client.troubleOf(error);
if (trouble) out.trouble = trouble;
return out;
}
/// A subagent OpenCode started. Its events belong to the chat that started
/// it, tagged with the subagent they came from.
export class Child {
root: string;
parent: string;
description: string | null = null;
sessionTitle: string | null = null;
name: string | null = null;
prompt: string | null = null;
model: string | null = null;
effort: string | null = null;
toolCallId: string | null = null;
background = false;
status: TaskStatus = "running";
activity: string | null = null;
summary: string | null = null;
startedAt: string = now();
endedAt: string | null = null;
usage: Usage | null = null;
tools = new Set<string>();
waiting = 0;
lastText: [string, string] | null = null;
announced = false;
constructor(root: string, parent: string) {
this.root = root; // the chat it works for, always a session the host knows
this.parent = parent; // the session that started it: the chat, or a subagent
}
task(id: string): TaskInfo {
const fromSession = this.sessionTitle !== null ? map.taskTitle(this.sessionTitle) || null : null;
const title =
this.description ??
fromSession ??
(this.prompt !== null ? map.firstLine(this.prompt) : null) ??
this.name ??
"Subagent";
const task: TaskInfo = { id, title, status: this.status };
if (this.toolCallId) task.toolCallId = this.toolCallId;
if (this.parent !== this.root) task.parentTaskId = this.parent;
if (this.name) task.name = this.name;
if (this.model) task.model = this.model;
if (this.effort) task.effort = this.effort;
if (this.prompt) task.prompt = this.prompt;
if (this.background) task.background = true;
if (this.activity) task.activity = this.activity;
if (this.summary) task.summary = this.summary;
if (this.startedAt) task.startedAt = this.startedAt;
if (this.endedAt) task.endedAt = this.endedAt;
if (this.usage) task.usage = this.usage;
task.toolUses = this.tools.size;
return task;
}
/// Ends the subagent unless it already ended: a cancelled subagent still
/// reports an error and goes idle afterwards.
settle(status: TaskStatus): void {
if (!map.isLive(this.status)) return;
this.status = status;
this.waiting = 0;
this.activity = null;
this.endedAt = now();
if (this.summary === null && this.lastText && this.lastText[1].trim()) this.summary = this.lastText[1];
}
/// What one of its own events says about it.
observe(event: AgentEventKind): void {
switch (event.event) {
case "tool_call_started":
this.tools.add(event.id);
if (event.status === "pending" || event.status === "running") this.activity = event.title;
break;
case "tool_call_updated":
if (event.id !== undefined) this.tools.add(event.id);
if (event.status === "running" && typeof event.title === "string") this.activity = event.title;
break;
case "text_delta":
if (this.lastText && event.mode === "append" && this.lastText[0] === event.itemId)
this.lastText[1] += event.text;
else this.lastText = [event.itemId, event.text];
break;
default:
break;
}
}
}
export interface QuestionFieldAnswer {
id: string;
header: string;
question: string;
kind?: string;
}
/// The labels the user chose for one pending question. The published id
/// is tried first, then the two names OpenCode uses: keying by position
/// would put an answer on the wrong question the moment the UI reorders
/// the fields.
export function fieldAnswer(field: QuestionFieldAnswer, values: Record<string, unknown>): string[] {
const named = (key: string): unknown =>
key && values && Object.prototype.hasOwnProperty.call(values, key) ? values[key] : undefined;
const raw = named(field.id) ?? named(field.header) ?? named(field.question);
if (Array.isArray(raw)) return raw.filter((value): value is string => typeof value === "string");
if (typeof raw === "string" && raw.trim()) return [raw];
if (typeof raw === "boolean" && field.kind === "boolean") return [String(raw)];
return [];
}
export class OpenCodeAgent {
instance: Instance;
scope: Scope.Scope;
env: Record<string, string>;
emit: (event: Event) => void;
id: string;
name: string;
server: client.LocalServer | null = null;
starting: Fiber.Fiber<client.LocalServer, unknown> | null = null;
// Bumped for every server this plugin starts: what a server was told
// (the tools server) dies with it.
generation = 0;
generatedPassword: string | null = null;
sessions = new Map<string, SessionState>();
runs = new Map<string, RunState>();
// Correlation only, not a second inbox. Keep uncertain deliveries after
// idle/transport failure so late native evidence resolves the original run.
inputRuns = new Map<string, RunState>();
streams = new Map<string, StreamEntry>();
approvals = new Map<string, { session: string; workspace: string }>();
questions = new Map<string, { pending: { session: string; workspace: string }; fields: QuestionFieldAnswer[] }>();
commands = new Map<string, unknown>();
catalogs = new Map<string, unknown>();
children = new Map<string, Child>();
tools = new Map<string, { url: string; key: string; generation: number }>();
toolsQueue = new Map<string, Effect.Effect<void, unknown, PluginServices>>();
/// `instance`: the server (`instances.ts`). `scope`: the plugin scope
/// its streams and servers end with. `env`: the login environment's
/// `HOME` and `OPENCODE_*` variables. `emit`: sends an agent event (set
/// once the agent is registered).
constructor({
scope,
instance,
env = {},
emit = () => {},
}: {
scope: Scope.Scope;
instance: Instance;
env?: Record<string, string>;
emit?: (event: Event) => void;
}) {
this.scope = scope;
this.instance = instance;
this.env = env;
this.emit = emit;
this.id = agentId(instance);
this.name = displayName(instance);
}
/// The handlers the host calls, by `agent/<method>` name. `before`
/// refreshes the login environment before each call, so the first call
/// waits for the configuration like the JavaScript `ready` promise did.
definition(
before: Effect.Effect<Record<string, string>, never, PluginServices> = Effect.succeed(this.env),
): EffectAgent {
const call = <S extends z.ZodType, A, E>(
schema: S,
handler: (params: z.infer<S>) => Effect.Effect<A, E, PluginServices>,
) =>
Effect.fn("OpenCode.handler")(
function* (this: OpenCodeAgent, params: unknown) {
this.env = yield* before;
return yield* handler(yield* parse("opencode handler", schema, params));
}.bind(this),
);
return {
id: this.id,
name: this.name,
initialize: call(schemas.empty, () => this.initialize()),
list_options: call(schemas.workspace, (params) => this.listOptions(params)),
list_commands: call(schemas.session, (params) => this.listCommands(params)),
list_sessions: call(schemas.workspace, (params) => this.listSessions(params)),
read_session: call(schemas.resume, (params) => this.readSession(params)),
list_skills: call(schemas.skills, (params) => this.listSkills(params)),
create_session: call(schemas.create, (params) => this.createSession(params)),
resume_session: call(schemas.resume, (params) => this.resumeSession(params)),
close_session: call(schemas.session, (params) => this.closeSession(params)),
fork_session: call(schemas.fork, (params) => this.forkSession(params)),
prompt: call(schemas.prompt, (params) => this.prompt(params)),
cancel: call(schemas.session, (params) => this.cancel(params)),
cancel_task: call(schemas.cancelTask, (params) => this.cancelTask(params)),
set_option: call(schemas.setOption, (params) => this.setOption(params)),
respond_to_approval: call(schemas.approval, (params) => this.respondToApproval(params)),
respond_to_question: call(schemas.question, (params) => this.respondToQuestion(params)),
rollback: call(schemas.rollback, (params) => this.rollback(params)),
compact: call(schemas.session, (params) => this.compact(params)),
usage_limits: call(schemas.empty, () => this.usageLimits()),
update: call(schemas.empty, () => this.update()),
authenticate: call(schemas.authenticate, (params) => this.authenticate(params)),
logout: call(schemas.empty, () => this.logout()),
};
}
// --- the server ------------------------------------------------------------------
/// A server the user runs themselves; `null` when this plugin starts one.
get external(): string | null {
return serverUrl(this.instance, this.env);
}
/// The credentials for the server. An instance's own password is the only
/// one sent to it; a server this plugin starts always has one, made up
/// for the launch when nobody chose one.
get auth(): Auth | null {
const username = this.env["OPENCODE_SERVER_USERNAME"] || client.DEFAULT_USERNAME;
if (this.instance.password) return { username, password: this.instance.password };
const external = this.external !== null;
const password = client.passwordFor(
external,
this.env["CONVERGENCE_OPENCODE_SERVER_PASSWORD"] ?? null,
this.env["OPENCODE_SERVER_PASSWORD"] ?? null,
);
if (password) return { username, password };
if (external) return null;
this.generatedPassword ??= client.generatedPassword();
return { username, password: this.generatedPassword };
}
/// The server's base URL, starting it the first time and again after it
/// died: a dead server would otherwise fail every later call.
base: (this: OpenCodeAgent) => Effect.Effect<string, unknown, PluginServices> = Effect.fn("OpenCode.base")(function* (
this: OpenCodeAgent,
) {
const external = this.external;
if (external) return external;
if (this.server?.alive) return this.server.base;
if (this.server) {
console.warn(`opencode: the server exited${this.server.logTail()}; restarting it`);
this.server = null;
this.catalogs.clear();
this.commands.clear();
}
let starting = this.starting;
if (!starting) {
const binary = command(this.instance, this.env);
const auth = this.auth;
starting = yield* client.LocalServer.start(auth, {
binary,
describe: Effect.fn("OpenCode.describeBinary")(function* () {
const located = yield* maintenance.locate();
return located.path;
}),
}).pipe(
Effect.tap((server) =>
Effect.sync(() => {
this.server = server;
this.generation += 1;
console.info(`opencode: server ${server.version} ready at ${server.base}`);
}),
),
Effect.ensuring(
Effect.sync(() => {
this.starting = null;
}),
),
Effect.forkIn(this.scope),
);
this.starting = starting;
}
return (yield* Fiber.join(starting)).base;
});
http: (
method: string,
path: string,
options?: client.RequestOptions,
) => Effect.Effect<unknown, unknown, PluginServices> = Effect.fn("OpenCode.http")(function* (
this: OpenCodeAgent,
method: string,
path: string,
options: client.RequestOptions = {},
) {
const base = yield* this.base();
const settled = yield* client.request(base, this.auth, method, path, options).pipe(Effect.result);
if (Result.isFailure(settled)) return yield* Effect.fail(this.explainRefusal(settled.failure, base));
return settled.success;
});
/// A server address no grant covers (OPENCODE_SERVER_URL, a stored
/// server) is refused by the host; say how to allow it rather than
/// "not granted".
explainRefusal(error: unknown, base: string): unknown {
if (!isRefusal(error) || !this.external) return error;
return new Error(
`the OpenCode server at ${base} is not allowed yet: set it as the External server in Settings > OpenCode and allow the connection on the card`,
);
}
get(path: string, query: Record<string, unknown>) {
return this.http("GET", path, { query });
}
post(path: string, query: Record<string, unknown>, body: unknown, extra: client.RequestOptions = {}) {
return this.http("POST", path, { query, body, ...extra });
}
postEmpty(path: string, query: Record<string, unknown>, body: unknown = {}, extra: client.RequestOptions = {}) {
return this.http("POST", path, { query, body, empty: true, ...extra });
}
patch(path: string, query: Record<string, unknown>, body: unknown) {
return this.http("PATCH", path, { query, body });
}
delete(path: string, query: Record<string, unknown>) {
return this.http("DELETE", path, { query, empty: true });
}
/// Stops the event streams and the server this plugin started.
shutdown: (this: OpenCodeAgent) => Effect.Effect<void, never, PluginServices> = Effect.fn("OpenCode.shutdown")(
function* (this: OpenCodeAgent) {
const fibers: Fiber.Fiber<void>[] = [];
for (const entry of this.streams.values()) {
entry.stopped = true;
this.stopStream(entry);
if (entry.fiber) {
fibers.push(entry.fiber);
entry.fiber = null;
}
}
this.streams.clear();
this.tools.clear();
yield* Fiber.interruptAll(fibers);
this.inputRuns.clear();
const server = this.server;
this.server = null;
if (server) yield* server.stop();
},
);
stopStream(entry: StreamEntry): void {
entry.stopped = true;
try {
if (typeof entry.body?.cancel === "function") entry.body.cancel();
else entry.body?.end?.();
} catch {
// Already closed.
}
}
// --- events ------------------------------------------------------------------------
sessionState(session: string): SessionState {
const state = this.sessions.get(session);
if (!state) throw new Error(`unknown opencode session ${session}`);
return state;
}
/// The chat a session's events go to, and the subagent it is: a
/// subagent runs in a session of its own, but the user watches one chat.
route(session: string): [string, string | null] {
const child = this.children.get(session);
return child ? [child.root, session] : [session, null];
}
emitFor(session: string, event: AgentEventKind): void {
const [root, task] = this.route(session);
const out: { sessionId: string; runId?: string; taskId?: string } = { sessionId: root };
const runId = this.runs.get(root)?.id;
if (runId) out.runId = runId;
if (task) out.taskId = task;
this.emit({ ...out, ...event });
}
notice(session: string, level: NoticeLevel, message: unknown): void {
this.emitFor(session, { event: "notice", level, message: String(message) });
}
/// Ends the run of `session`, when one is active. Removing it first
/// makes exactly one `run_finished` per run.
finish(session: string, result: RunOutcome, expected?: RunState): void {
const run = this.runs.get(session);
if (!run || (expected && run !== expected)) return;
run.outcome = run.stopping ? outcome.cancelled() : result;
if (run.outcome.status === "cancelled" && run.delivery)
this.inputRuns.delete(`${session}/${run.delivery.nativeInputId}`);
this.runs.delete(session);
this.emit({ sessionId: session, runId: run.id, event: "run_finished", outcome: run.outcome });
}
/// Called only with native assistant-parent or completed-command evidence.
/// Use the captured run, never the session's possibly newer active run.
consumeInput(session: string, run: RunState): void {
const delivery = run.delivery;
if (!delivery || delivery.consumed || run.stopping || run.outcome?.status === "cancelled") return;
// Closing the session or superseding an alias removes this attempt's authority.
if (this.inputRuns.get(`${session}/${delivery.nativeInputId}`) !== run) return;
delivery.consumed = true;
this.inputRuns.delete(`${session}/${delivery.nativeInputId}`);
this.emit({
sessionId: session,
runId: run.id,
event: "input_consumed",
inputId: delivery.inputId,
nativeInputId: delivery.nativeInputId,
});
}
// --- event stream ----------------------------------------------------------------------
signalReady(entry: StreamEntry) {
return Deferred.complete(entry.ready, Effect.void);
}
/// Subscribes a folder to the event stream, and waits until the first
/// connection is open (or has failed and is being retried), so the
/// events of a prompt sent right after are not missed.
ensureStream: (directory: string) => Effect.Effect<void, unknown, PluginServices> = Effect.fn(
"OpenCode.ensureStream",
)(function* (this: OpenCodeAgent, directory: string) {
yield* this.base();
const known = this.streams.get(directory);
if (known) return yield* Deferred.await(known.ready);
const ready = yield* Deferred.make<void>();
const entry: StreamEntry = { stopped: false, body: null, fiber: null, ready };
this.streams.set(directory, entry);
const fiber = yield* this.supervise(directory, entry).pipe(
Effect.result,
Effect.andThen((outcome) =>
Result.isFailure(outcome)
? Effect.sync(() =>
console.error(`opencode: the event stream of ${directory} failed: ${errorMessage(outcome.failure)}`),
)
: Effect.void,
),
Effect.ensuring(
Effect.sync(() => {
void Deferred.doneUnsafe(entry.ready, Effect.void);
if (this.streams.get(directory) === entry) this.streams.delete(directory);
}),
),
Effect.forkIn(this.scope),
);
entry.fiber = fiber;
return yield* Deferred.await(ready);
});
/// Keeps a workspace subscribed to `GET /event`, reconnecting while the
/// server lives: a transport blip is not the end of a run. Only a server
/// that is gone fails the runs.
supervise: (directory: string, entry: StreamEntry) => Effect.Effect<void, unknown, PluginServices> = Effect.fn(
"OpenCode.supervise",
)(function* (this: OpenCodeAgent, directory: string, entry: StreamEntry) {
let backoff = RECONNECT_MIN;
let attached = false;
let failingSince: number | null = null;
for (;;) {
const started = Date.now();
const settled = yield* Effect.result(this.stream(directory, attached, entry));
const message = Result.isSuccess(settled) ? "the opencode event stream closed" : errorMessage(settled.failure);
// From here until the next connection, events are lost.
entry.droppedAt ??= Date.now();
yield* this.signalReady(entry);
if (entry.stopped) return;
attached = true;
// A connection that held is not part of the same failure.
if (Date.now() - started >= RECONNECT_RESET) {
backoff = RECONNECT_MIN;
failingSince = null;
}
failingSince ??= Date.now();
if (!this.canReconnect(failingSince)) {
this.failRunsIn(directory, message);
return;
}
console.warn(`opencode: ${message}; reconnecting to the event stream of ${directory} in ${backoff} ms`);
yield* Effect.sleep(backoff);
backoff = Math.min(backoff * 2, RECONNECT_MAX);
if (entry.stopped) return;
const base = yield* Effect.result(this.base());
if (Result.isFailure(base)) {
this.failRunsIn(directory, errorMessage(base.failure));
return;
}
}
});
/// A server this plugin started that exited took its sessions with it; a
/// server somebody else runs may come back, but not for ever.
canReconnect(failingSince: number): boolean {
if (this.external) return Date.now() - failingSince < RECONNECT_GIVE_UP;
return !!this.server?.alive;
}
/// Reads `GET /event` for one workspace until it ends. `reattach` is set
/// for every connection after the first: what the server blocked on
/// while we were away is recovered before the new events are read.
stream: (directory: string, reattach: boolean, entry: StreamEntry) => Effect.Effect<void, unknown, PluginServices> =
Effect.fn("OpenCode.stream")(function* (
this: OpenCodeAgent,
directory: string,
reattach: boolean,
entry: StreamEntry,
) {
const base = yield* this.base();
const response = yield* client
.openEvents(base, this.auth, directory)
.pipe(Effect.mapError((error) => this.explainRefusal(error, base)));
entry.body = response.body;
yield* this.signalReady(entry);
if (entry.stopped) {
this.stopStream(entry);
return;
}
if (reattach) {
yield* this.rehydrate(directory, entry.droppedAt ?? Date.now());
entry.droppedAt = null;
}
const mapper = new map.Mapper();
const agentSelf = this;
yield* Stream.fromAsyncIterable(parseSse(response.body), (cause) => cause).pipe(
Stream.takeWhile(() => !entry.stopped),
Stream.runForEach((message) =>
Effect.gen(function* () {
let envelope: unknown;
try {
envelope = JSON.parse(message.data);
} catch {
return;
}
for (const mapped of mapper.event(envelope)) {
const outcome = yield* agentSelf.apply(mapped, directory).pipe(Effect.result);
if (Result.isFailure(outcome))
yield* Effect.sync(() =>
console.warn(`opencode: an event could not be handled: ${errorMessage(outcome.failure)}`),
);
}
}),
),
);
});
/// Re-reads what the server still blocks on after a reconnect: requests
/// asked while the stream was down are announced, requests the server
/// forgot are withdrawn, so no card waits for an answer that cannot come.
/// A run whose turn ended while the stream was down (its `session.idle`
/// lost) ends too, or the chat would stay running for ever.
rehydrate = Effect.fn("OpenCode.rehydrate")(function* (
this: OpenCodeAgent,
directory: string,
droppedAt: number = Date.now(),
) {
yield* this.settleIdleRuns(directory, droppedAt);
const agentSelf = this;
const settled = yield* Effect.result(
Effect.gen(function* () {
return {
permissions: yield* agentSelf.get("/permission", { directory }),
questions: yield* agentSelf.get("/question", { directory }),
};
}),
);
if (Result.isFailure(settled)) {
console.warn(`opencode: could not re-read the pending requests: ${errorMessage(settled.failure)}`);
return;
}
const permissions = arr(settled.success.permissions);
const questions = arr(settled.success.questions);
const mine = (session: string): boolean => this.sessions.get(session)?.workspace === directory;
for (const raw of permissions) {
const request = obj(raw);
const session = request["sessionID"];
if (typeof session !== "string" || !mine(session) || typeof request["id"] !== "string") continue;
if (this.approvals.has(request["id"])) continue;
yield* this.apply(
{
type: "approval",
session,
request: map.approval(request, null),
},
directory,
);
}
for (const raw of questions) {
const request = obj(raw);
const session = request["sessionID"];
if (typeof session !== "string" || !mine(session) || typeof request["id"] !== "string") continue;
if (this.questions.has(request["id"])) continue;
yield* this.apply({ type: "question", session, request: map.question(request) }, directory);
}
const liveApprovals = new Set(permissions.map((request) => obj(request)["id"]));
for (const [id, pending] of [...this.approvals]) {
if (pending.workspace === directory && !liveApprovals.has(id))
yield* this.apply({ type: "resolved", session: pending.session, approval: id }, directory);
}
const liveQuestions = new Set(questions.map((request) => obj(request)["id"]));
for (const [id, pending] of [...this.questions]) {
if (pending.pending.workspace === directory && !liveQuestions.has(id)) {
yield* this.apply({ type: "resolved", session: pending.pending.session, question: id }, directory);
}
}
});
/// Ends the runs of `directory` that began before the stream dropped and
/// that the server no longer reports as busy (`GET /session/status`
/// lists the sessions at work; an idle one is left out). A run that began
/// later may not be busy yet, so it waits for its own events.
settleIdleRuns: (directory: string, droppedAt: number) => Effect.Effect<void, unknown, PluginServices> = Effect.fn(
"OpenCode.settleIdleRuns",
)(function* (this: OpenCodeAgent, directory: string, droppedAt: number) {
const candidates = [...this.runs].filter(
([session, run]) => this.sessions.get(session)?.workspace === directory && (run.startedAt ?? 0) < droppedAt,
);
if (!candidates.length) return;
const settled = yield* Effect.result(this.get("/session/status", { directory }));
if (Result.isFailure(settled)) {
console.warn(`opencode: could not re-read the sessions' status: ${errorMessage(settled.failure)}`);
return;
}
const status = settled.success;
if (!status || typeof status !== "object" || Array.isArray(status)) return;
const table = obj(status);
for (const [session, run] of candidates) {
const type = obj(table[session])["type"];
if (type === "busy" || type === "retry" || type === "running") continue;
// Still the same run: a new prompt may have replaced it meanwhile.
if (this.runs.get(session) !== run) continue;
yield* this.apply({ type: "idle", session }, directory);
}
});
apply: (mapped: Mapped, directory: string) => Effect.Effect<unknown, unknown, PluginServices> = Effect.fn(
"OpenCode.apply",
)(function* (this: OpenCodeAgent, mapped: Mapped, directory: string) {
switch (mapped.type) {
case "event":
return yield* this.applyEvent(mapped.session, mapped.event);
case "child":
return this.adopt(mapped.child);
case "task_link":
return this.link(mapped.link);
case "task_result": {
const { result } = mapped;
return this.updateChild(result.child, (known) => {
if (result.summary !== null) known.summary = result.summary;
known.settle(result.status);
});
}
case "assistant": {
const state = this.sessions.get(mapped.session);
if (state?.workspace === directory && mapped.parent) {
const run = this.inputRuns.get(`${mapped.session}/${mapped.parent}`);
if (run) this.consumeInput(mapped.session, run);
}
if (state && mapped.provider && mapped.model) state.ranOn = [mapped.provider, mapped.model];
return this.updateChild(mapped.session, (known) => {
known.model = mapped.model ?? known.model;
known.effort = mapped.variant ?? known.effort;
});
}
case "idle": {
// A subagent going idle ends the task, not the chat's run: the
// parent still works with what the subagent found.
if (this.children.has(mapped.session))
return this.updateChild(mapped.session, (known) => known.settle("completed"));
const error = this.runs.get(mapped.session)?.error;
return this.finish(mapped.session, error ? outcome.failed(error) : outcome.completed());
}
case "failed": {
const child = this.children.get(mapped.session);
if (child) {
// A subagent the user stopped reports the abort as an error;
// that is not news.
const stopped = child.status === "cancelled";
if (!stopped) this.notice(mapped.session, "error", mapped.message);
return this.updateChild(mapped.session, (known) => {
if (!stopped && known.summary === null) known.summary = mapped.message;
known.settle("failed");
});
}
const run = this.runs.get(mapped.session);
if (run) run.error = mapped.message;
return this.notice(mapped.session, "error", mapped.message);
}
case "approval":
return yield* this.onApproval(mapped.session, mapped.request, directory);
case "question":
return this.onQuestion(mapped.session, mapped.request, directory);
case "resolved":
if (mapped.approval !== undefined) {
// Only requests we announced: the server sends the reply event
// for our own answers too.
if (!this.approvals.delete(mapped.approval)) return;
this.requestDone(mapped.session);
return this.emitFor(mapped.session, { event: "approval_resolved", id: mapped.approval });
}
if (mapped.question === undefined) return;
if (!this.questions.delete(mapped.question)) return;
this.requestDone(mapped.session);
return this.emitFor(mapped.session, { event: "question_resolved", id: mapped.question });
default:
return undefined;
}
});
applyEvent: (session: string, event: AgentEventKind) => Effect.Effect<unknown, unknown, PluginServices> = Effect.fn(
"OpenCode.applyEvent",
)(function* (this: OpenCodeAgent, session: string, event: AgentEventKind) {
const child = this.children.get(session);
if (event.event === "usage") {
const { event: _kind, ...usage } = event;
// A subagent's usage is its own, carried on its task; it never
// replaces the chat's context meter.
if (child)
return this.updateChild(session, (known) => {
known.usage = map.addUsage(known.usage, usage);
});
const window = yield* this.contextWindow(session);
if (window) usage.contextWindow = window;
return this.emitFor(session, { event: "usage", ...usage });
}
// A subagent's title is its own session title: the task's name.
if (event.event === "session_info" && child)
return this.updateChild(session, (known) => {
known.sessionTitle = event.title ?? known.sessionTitle;
});
this.updateChild(session, (known) => known.observe(event));
return this.emitFor(session, event);
});
onApproval: (
session: string,
request: ApprovalRequest,
directory: string,
) => Effect.Effect<void, unknown, PluginServices> = Effect.fn("OpenCode.onApproval")(function* (
this: OpenCodeAgent,
session: string,
request: ApprovalRequest,
directory: string,
) {
// In full access the chat asked for no prompts, but OpenCode still
// asks: a subagent session carries its own rules, and the doom-loop
// guard is not covered by the ruleset at all.
if (this.repliesAutomatically(session)) {
// `once`, never `always`: an `always` grant is stored per folder on
// the server and would widen what a supervised chat there may do.
const auto = yield* this.postEmpty(`/permission/${request.id}/reply`, { directory }, { reply: "once" }).pipe(
Effect.result,
);
if (Result.isSuccess(auto)) return;
console.warn(`opencode: could not approve a permission automatically: ${errorMessage(auto.failure)}`);
}
if (!this.approvals.has(request.id)) {
this.approvals.set(request.id, { session, workspace: directory });
this.requestAsked(session);
}
this.emitFor(session, { event: "approval", ...request });
});
onQuestion(session: string, request: QuestionRequest, directory: string): void {
const fields = request.fields.map((field) => ({
id: field.id,
header: field.description ?? "",
question: field.label,
kind: field.kind,
}));
if (!this.questions.has(request.id)) {
this.questions.set(request.id, { pending: { session, workspace: directory }, fields });
this.requestAsked(session);
} else {
const pending = this.questions.get(request.id);
if (pending) pending.fields = fields;
}
this.emitFor(session, { event: "question", ...request });
}
// --- subagents --------------------------------------------------------------------------------
/// Records a subagent session and announces it to its chat. A subagent
/// may start one of its own, so the chat is found by walking the chain.
adopt(child: map.ChildInfo): void {
const root = this.rootOf(child.parent);
// A session of some other client of this server.
if (root === null) return;
if (!this.children.has(child.id)) this.children.set(child.id, new Child(root, child.parent));
this.updateChild(child.id, (known) => {
if (child.title !== null) known.sessionTitle = child.title;
if (known.name === null) known.name = child.agent;
});
}
/// Links a `task` call to the subagent it started, and ends the subagent
/// when the call reports how it went.
link(link: TaskLink): void {
const root = this.rootOf(link.parent);
if (root === null) return;
if (!this.children.has(link.child)) this.children.set(link.child, new Child(root, link.parent));
this.updateChild(link.child, (known) => {
const fresh = known.toolCallId === null;
// `task_id` resumes a subagent that already ended: a new call gives
// it more work.
const resumed = !fresh && known.toolCallId !== link.toolCallId;
if (resumed && link.outcome === null && !map.isLive(known.status)) {
known.status = "running";
known.endedAt = null;
known.summary = null;
known.lastText = null;
}
if (fresh) {
known.toolCallId = link.toolCallId;
known.startedAt = link.startedAt ?? known.startedAt;
}
if (!resumed || known.description === null) {
known.description = link.title ?? known.description;
known.prompt = link.prompt ?? known.prompt;
}
known.name = link.agent ?? known.name;
known.model = link.model ?? known.model;
known.background = known.background || link.background;
if (link.outcome) {
if (link.outcome.summary !== null) known.summary = link.outcome.summary;
known.settle(link.outcome.status);
if (link.endedAt) known.endedAt = link.endedAt;
}
});
}
/// Applies `change` to a subagent and announces it when that changed
/// what the chat shows.
updateChild(id: string, change: (known: Child) => void): void {
const known = this.children.get(id);
if (!known) return;
const before = JSON.stringify(known.task(id));
change(known);
const task = known.task(id);
const announce = before !== JSON.stringify(task) || !known.announced;
known.announced = true;
if (!announce) return;
const out: { sessionId: string; runId?: string } = { sessionId: known.root };
const runId = this.runs.get(known.root)?.id;
if (runId) out.runId = runId;
this.emit({ ...out, event: "task", ...task });
}
/// The chat a session belongs to, following the subagent chain up.
rootOf(session: string): string | null {
if (this.sessions.has(session)) return session;
const root = this.children.get(session)?.root;
return root !== undefined && this.sessions.has(root) ? root : null;
}
/// One of a subagent's requests was answered or withdrawn.
requestDone(session: string): void {
this.updateChild(session, (known) => {
known.waiting = Math.max(0, known.waiting - 1);
if (known.waiting === 0 && known.status === "waiting") known.status = "running";
});
}
/// A subagent asks the user something and waits for the answer.
requestAsked(session: string): void {
this.updateChild(session, (known) => {
known.waiting += 1;
if (known.status === "running") known.status = "waiting";
});
}
/// `task` and every subagent below it, deepest last.
subtree(task: string): string[] {
const found = [task];
for (let index = 0; index < found.length; index += 1) {
for (const [id, child] of this.children) if (child.parent === found[index] && !found.includes(id)) found.push(id);
}
return found;
}
/// Withdraws what the given sessions wait on, telling the server no, so
/// a stopped subagent leaves no card nobody can answer.
rejectRequests: (
sessions: Set<string>,
workspace: string,
announce: boolean,
) => Effect.Effect<void, unknown, PluginServices> = Effect.fn("OpenCode.rejectRequests")(function* (
this: OpenCodeAgent,
sessions: Set<string>,
workspace: string,
announce: boolean,
) {
const query = { directory: workspace };
for (const [id, pending] of [...this.approvals]) {
if (!sessions.has(pending.session)) continue;
this.approvals.delete(id);
yield* this.postEmpty(`/permission/${id}/reply`, query, { reply: "reject" }).pipe(Effect.ignore);
if (announce) this.emitFor(pending.session, { event: "approval_resolved", id });
}
for (const [id, pending] of [...this.questions]) {
if (!sessions.has(pending.pending.session)) continue;
this.questions.delete(id);
yield* this.postEmpty(`/question/${id}/reject`, query, {}).pipe(Effect.ignore);
if (announce) this.emitFor(pending.pending.session, { event: "question_resolved", id });
}
});
/// Stops every subagent of a chat. The server is asked for the children
/// too: one started while the event stream was down would otherwise run
/// on after the user cancelled.
abortChildren: (session: string, workspace: string) => Effect.Effect<void, unknown, PluginServices> = Effect.fn(
"OpenCode.abortChildren",
)(function* (this: OpenCodeAgent, session: string, workspace: string) {
const query = { directory: workspace };
const seen = new Set([session]);
const pending = [...this.children].filter(([, child]) => child.root === session).map(([id]) => id);
for (const id of pending) seen.add(id);
const frontier = [session, ...pending];
for (let index = 0; index < frontier.length; index += 1) {
const current = frontier[index];
if (current === undefined) continue;
const settled = yield* Effect.result(this.get(`/session/${current}/children`, query));
const found = Result.isSuccess(settled) ? settled.success : [];
for (const raw of Array.isArray(found) ? found : []) {
const child = obj(raw);
if (typeof child["id"] !== "string" || seen.has(child["id"])) continue;
seen.add(child["id"]);
pending.push(child["id"]);
frontier.push(child["id"]);
}
}
for (const child of pending) {
yield* this.postEmpty(`/session/${child}/abort`, query, {}).pipe(Effect.ignore);
this.updateChild(child, (known) => known.settle("cancelled"));
}
});
/// A session's messages and those of every subagent below it, as
/// `Map(session -> { messages, children })`.
history: (session: string, workspace: string) => Effect.Effect<Map<string, SessionHistory>, unknown, PluginServices> =
Effect.fn("OpenCode.history")(function* (this: OpenCodeAgent, session: string, workspace: string) {
const query = { directory: workspace };
const sessions = new Map<string, map.SessionHistory>();
const frontier: [string, number][] = [[session, 0]];
while (frontier.length) {
const next = frontier.pop();
if (!next) continue;
const [current, depth] = next;
if (sessions.has(current)) continue;
const fetched = yield* Effect.result(this.get(`/session/${current}/message`, query));
let messages: unknown[];
if (Result.isFailure(fetched)) {
// The chat itself must be readable; a subagent the server lost only
// loses its own transcript.
if (current === session) return yield* Effect.fail(fetched.failure);
console.warn(`opencode: could not read subagent ${current}: ${errorMessage(fetched.failure)}`);
messages = [];
} else {
messages = Array.isArray(fetched.success) ? fetched.success : [];
}
const children = yield* this.get(`/session/${current}/children`, query).pipe(
Effect.map((value: unknown) => (Array.isArray(value) ? value : [])),
Effect.orElseSucceed(() => []),
);
if (depth < MAX_SUBAGENT_DEPTH) {
for (const child of map.linkedChildren(messages)) frontier.push([child, depth + 1]);
for (const raw of children) {
const child = obj(raw);
if (typeof child["id"] === "string") frontier.push([child["id"], depth + 1]);
}
}
sessions.set(current, { messages, children });
}
return sessions;
});
/// Remembers the subagents history shows still at work, so their events
/// reach the chat and they can be stopped.
rememberLiveTasks(root: string, items: TranscriptItem[]): void {
const stack = [...items];
while (stack.length) {
const item = stack.pop();
if (item?.role !== "task") continue;
stack.push(...(item.items ?? []));
const task = item.task;
if (!map.isLive(task.status) || this.children.has(task.id)) continue;
const child = new Child(root, task.parentTaskId ?? root);
Object.assign(child, {
description: task.title,
name: task.name ?? null,
prompt: task.prompt ?? null,
model: task.model ?? null,
effort: task.effort ?? null,
toolCallId: task.toolCallId ?? null,
background: !!task.background,
status: task.status,
summary: task.summary ?? null,
startedAt: task.startedAt ?? child.startedAt,
usage: task.usage ?? null,
announced: true,
});
this.children.set(task.id, child);
}
}
/// Whether this chat answers its own permission requests.
repliesAutomatically(session: string): boolean {
const root = this.rootOf(session);
if (root === null) return false;
return permission.repliesAutomatically(permissionMode.selected(this.sessions.get(root)?.options));
}
/// The model catalog of a workspace, read once and reused; `refresh`
/// reads it again (a new login or an edited configuration).
catalog: (workspace: string, refresh: boolean) => Effect.Effect<unknown, unknown, PluginServices> = Effect.fn(
"OpenCode.catalog",
)(function* (this: OpenCodeAgent, workspace: string, refresh: boolean) {
if (!refresh && this.catalogs.has(workspace)) return this.catalogs.get(workspace);
const fetched = yield* this.get("/config/providers", { directory: workspace }).pipe(Effect.result);
if (Result.isFailure(fetched))
return yield* Effect.fail(annotate("reading the opencode model catalog", fetched.failure));
this.catalogs.set(workspace, fetched.success);
return fetched.success;
});
/// The context window of the model `session` runs, when the catalog
/// knows it: the model the last assistant message ran on (a chat that
/// never chose one runs the server's default), else the chosen option.
contextWindow: (session: string) => Effect.Effect<number | null, unknown, PluginServices> = Effect.fn(
"OpenCode.contextWindow",
)(function* (this: OpenCodeAgent, session: string) {
const root = this.rootOf(session);
if (root === null) return null;
const state = this.sessions.get(root);
if (!state) return null;
const pair = state.ranOn ?? splitModel(state.options?.[OPTION_MODEL]);
if (!pair) return null;
const settled = yield* Effect.result(this.catalog(state.workspace, false));
if (Result.isFailure(settled)) return null;
try {
return contextWindow(settled.success, pair[0], pair[1]);
} catch {
return null;
}
});
/// Fails every run of a workspace whose event stream died.
failRunsIn(directory: string, message: unknown): void {
for (const session of [...this.runs.keys()]) {
if (this.sessions.get(session)?.workspace !== directory) continue;
this.notice(session, "error", message);
this.finish(session, outcome.failed(message));
}
}
// --- options --------------------------------------------------------------------------------
/// The version of the server. One this plugin started checked its own
/// version while starting; one somebody else runs is checked here,
/// because a remote server that is too old fails later in ways that name
/// the wrong cause.
version: (this: OpenCodeAgent) => Effect.Effect<string, unknown, PluginServices> = Effect.fn("OpenCode.version")(
function* (this: OpenCodeAgent) {
yield* this.base();
if (!this.external && this.server) return this.server.version;
const checked = yield* this.get("/global/health", {}).pipe(Effect.result);
if (Result.isFailure(checked))
return yield* Effect.fail(annotate("reading the opencode server version", checked.failure));
const health = checked.success;
yield* Effect.try({
try: () => client.checkVersion(obj(health)["version"]),
catch: (error) => error,
});
return String(obj(health)["version"] ?? "");
},
);
/// Pushes the chat's permission rules onto an OpenCode session. A
/// session keeps the rules it was created with, so this runs on every
/// path that starts working with one.
pushRules: (
session: string,
workspace: string,
mode: string,
known?: unknown,
) => Effect.Effect<void, unknown, PluginServices> = Effect.fn("OpenCode.pushRules")(function* (
this: OpenCodeAgent,
session: string,
workspace: string,
mode: string,
known?: unknown,
) {
const wanted = permission.ruleset(mode);
const query = { directory: workspace };
const current = known !== undefined ? known : obj(yield* this.get(`/session/${session}`, query))["permission"];
// The server appends rather than replaces, so pushing what is already
// in force only makes the list longer.
if (permission.alreadyApplies(current, wanted)) return;
const pushed = yield* this.patch(`/session/${session}`, query, { permission: wanted }).pipe(Effect.result);
if (Result.isFailure(pushed))
return yield* Effect.fail(
new Error(`setting the permissions of opencode session ${session}: ${errorMessage(pushed.failure)}`),
);
});
/// The model catalog and the modes of a workspace: the server first, the
/// `opencode` command line when it cannot answer. Both are live sources;
/// when neither answers, the failure is reported.
inventory: (workspace: string) => Effect.Effect<{ providers: unknown; agents: unknown }, unknown, PluginServices> =
Effect.fn("OpenCode.inventory")(function* (this: OpenCodeAgent, workspace: string) {
const agentSelf = this;
const fromServer = yield* Effect.gen(function* () {
const providers = yield* agentSelf.catalog(workspace, true);
const agents = yield* agentSelf
.get("/agent", { directory: workspace })
.pipe(Effect.mapError((error) => annotate("reading the opencode agents", error)));
return { providers, agents };
}).pipe(Effect.result);
if (Result.isSuccess(fromServer)) return fromServer.success;
const serverError = fromServer.failure;
// A server that refused the password, or one somebody else runs, is
// not answered for by the local command line.
if (this.external || client.troubleOf(serverError)) return yield* Effect.fail(serverError);
console.warn(
`opencode: the server could not be read (${errorMessage(serverError)}); asking the command line instead`,
);
const fromCli = yield* cli
.inventory(command(this.instance, this.env) ?? "opencode", workspace)
.pipe(Effect.result);
if (Result.isFailure(fromCli))
return yield* Effect.fail(
new Error(
`${errorMessage(fromCli.failure)} (the opencode server failed first: ${errorMessage(serverError)})`,
),
);
const found = fromCli.success;
this.catalogs.set(workspace, found.providers);
return found;
});
options: (
workspace: string,
current: Record<string, unknown>,
) => Effect.Effect<ConfigOption[], unknown, PluginServices> = Effect.fn("OpenCode.options")(function* (
this: OpenCodeAgent,
workspace: string,
current: Record<string, unknown>,
) {
const { providers, agents } = yield* this.inventory(workspace);
return buildOptions(providers, agents, current);
});
commandsFor: (workspace: string) => Effect.Effect<unknown[], unknown, PluginServices> = Effect.fn(
"OpenCode.commandsFor",
)(function* (this: OpenCodeAgent, workspace: string) {
const fetched = yield* this.get("/command", { directory: workspace }).pipe(Effect.result);
if (Result.isFailure(fetched))
return yield* Effect.fail(new Error(`reading opencode commands: ${errorMessage(fetched.failure)}`));
const list = Array.isArray(fetched.success) ? fetched.success : [];
this.commands.set(workspace, list);
return list;
});
/// The slash command a prompt invokes, if it starts with one.
slashCommandOf: (workspace: string, text: string) => Effect.Effect<SlashInvocation | null, unknown, PluginServices> =
Effect.fn("OpenCode.slashCommandOf")(function* (this: OpenCodeAgent, workspace: string, text: string) {
if (typeof text !== "string" || !text.startsWith("/")) return null;
const stored = this.commands.get(workspace);
let commands = Array.isArray(stored) ? stored : undefined;
if (!commands) {
const settled = yield* Effect.result(this.commandsFor(workspace));
if (Result.isFailure(settled)) return null;
commands = settled.success;
}
return slashCommand(text, commands);
});
// --- plugin tools ----------------------------------------------------------------------------
/// Offers a session's plugin tools to OpenCode: the host serves them on
/// its loopback MCP server (`host/mcp.serve`), and OpenCode is told about
/// that server for the workspace (`POST /mcp`), where it names each tool
/// `convergence_<tool>`. One server per workspace, because OpenCode's MCP
/// servers belong to a folder rather than a session; the host tells the
/// calling chat apart by the running turn. Offers for one folder run one
/// at a time.
offerTools: (workspace: string, tools: SessionState["tools"]) => Effect.Effect<void, unknown, PluginServices> =
Effect.fn("OpenCode.offerTools")(function* (this: OpenCodeAgent, workspace: string, tools: SessionState["tools"]) {
const previous = this.toolsQueue.get(workspace) ?? Effect.void;
const gate = yield* Deferred.make<void, unknown>();
const tail: Effect.Effect<void, unknown, PluginServices> = Deferred.await(gate);
this.toolsQueue.set(workspace, tail);
const exit = yield* previous.pipe(
Effect.ignore,
Effect.andThen(this.offerToolsNow(workspace, Array.isArray(tools) ? tools : [])),
Effect.exit,
);
yield* Deferred.done(gate, exit);
if (this.toolsQueue.get(workspace) === tail) this.toolsQueue.delete(workspace);
return yield* exit;
});
offerToolsNow: (workspace: string, tools: SessionState["tools"]) => Effect.Effect<void, unknown, PluginServices> =
Effect.fn("OpenCode.offerToolsNow")(function* (
this: OpenCodeAgent,
workspace: string,
tools: SessionState["tools"],
) {
const known = this.tools.get(workspace) ?? null;
if (!tools.length && !known) return;
const base = yield* this.base();
// The tools server listens on this machine's loopback only.
if (!client.isLoopback(base)) {
if (tools.length)
return yield* Effect.fail(new Error("plugin tools are offered only to an OpenCode server on this computer"));
return;
}
const key = JSON.stringify(tools);
let url = known?.url ?? null;
if (!known || known.key !== key) {
const host = yield* Host;
const served = obj(
yield* host.call("host/mcp.serve", compact({ agentId: this.id, workspace, tools, url: url ?? undefined })),
);
if (typeof served["url"] !== "string")
return yield* Effect.fail(new Error("the host did not answer with a tools server address"));
url = served["url"];
}
const generation = this.external ? 0 : this.generation;
if (!known || known.url !== url || known.key !== key || known.generation !== generation) {
// Added again whenever the list changes: OpenCode reads a server's
// tools once, when it connects.
const status = yield* this.post(
"/mcp",
{ directory: workspace },
{ name: MCP_SERVER, config: { type: "remote", url, enabled: true, oauth: false } },
);
const entry = obj(status)[MCP_SERVER];
const detail = obj(entry);
if (entry && detail["status"] !== "connected" && detail["status"] !== "disabled") {
return yield* Effect.fail(
new Error(
`OpenCode could not connect to the Divergence tools server (${String(detail["status"])}${detail["error"] ? `: ${String(detail["error"])}` : ""})`,
),
);
}
}
if (url) this.tools.set(workspace, { url, key, generation });
});
/// Offers the session's tools, telling the chat when they cannot reach
/// OpenCode rather than failing the session over them.
offerSessionTools: (session: string) => Effect.Effect<void, unknown, PluginServices> = Effect.fn(
"OpenCode.offerSessionTools",
)(function* (this: OpenCodeAgent, session: string) {
const state = this.sessions.get(session);
if (!state) return;
const result = yield* Effect.result(this.offerTools(state.workspace, state.tools));
if (Result.isFailure(result)) {
this.notice(session, "warning", `Plugin tools are not available to OpenCode: ${errorMessage(result.failure)}`);
}
});
// --- agent methods ----------------------------------------------------------------------------
initialize: (this: OpenCodeAgent) => Effect.Effect<AgentInfo, unknown, PluginServices> = Effect.fn(
"OpenCode.initialize",
)(function* (this: OpenCodeAgent) {
const external = this.external;
const info: AgentInfo = {
id: this.id,
name: this.name,
description: external
? `The OpenCode coding agent, on the server at ${external}.`
: "The OpenCode coding agent, running as a local HTTP server.",
icon: ICON,
capabilities: {
reasoning: true,
images: true,
approvals: true,
questions: true,
sessionList: true,
sessionHistory: true,
resume: true,
slashCommands: true,
steer: false,
// Messages can be forgotten without the server touching files,
// which lets Convergence own the file side (its checkpoints).
rollback: true,
cancel: true,
skills: true,
fork: true,
compact: true,
usageLimits: false,
subagents: true,
cancelTask: true,
},
authMethods: [],
status: { state: "ready" },
family: FAMILY,
// Sessions live on the server, so two agents pointing at one server
// can continue each other's chats; a local server shares with nobody.
continuationKey: external ?? "",
};
const checked = yield* this.version().pipe(Effect.result);
if (Result.isFailure(checked)) {
const error = checked.failure;
// Which one it is decides what the user does: install the program,
// upgrade it, point at a 1.x one, or fix the password.
const trouble = client.troubleOf(error);
info.status =
trouble?.kind === "auth_rejected"
? { state: "auth_required", message: trouble.message }
: { state: "unavailable", message: errorMessage(trouble ?? error) };
return info;
}
const version: string | undefined = checked.success;
if (version) info.version = version;
// A server somebody else runs is not ours to upgrade.
if (!external) info.maintenance = yield* maintenance.describe(version || null);
let methods: unknown = {};
const settled = yield* Effect.result(this.get("/provider/auth", {}));
if (Result.isSuccess(settled)) methods = settled.success ?? {};
else console.warn(`opencode: could not read the sign-in methods: ${errorMessage(settled.failure)}`);
const table = obj(methods);
info.authMethods = Object.keys(table)
.sort()
.map((provider) => ({
id: provider,
name: provider,
description: arr(table[provider])
.map((method) => String(obj(method)["label"] ?? ""))
.join(", "),
}));
return info;
});
listOptions: (params: { workspace?: string }) => Effect.Effect<{ options: ConfigOption[] }, unknown, PluginServices> =
Effect.fn("OpenCode.listOptions")(function* (this: OpenCodeAgent, params: { workspace?: string }) {
return { options: yield* this.options(String(params.workspace ?? ""), {}) };
});
listCommands: (params: {
sessionId: string;
}) => Effect.Effect<{ commands: SlashCommand[] }, unknown, PluginServices> = Effect.fn("OpenCode.listCommands")(
function* (this: OpenCodeAgent, params: { sessionId: string }) {
const state = this.sessionState(params.sessionId);
const commands = yield* this.commandsFor(state.workspace);
return {
commands: commands.flatMap((entry) => {
const record = obj(entry);
if (typeof record["name"] !== "string") return [];
const hints = arr(record["hints"]);
const head = hints[0];
return [
compact<SlashCommand>({
name: record["name"],
description: typeof record["description"] === "string" ? record["description"] : "",
inputHint: typeof head === "string" ? head : undefined,
source: "agent",
}),
];
}),
};
},
);
listSessions: (params: {
workspace?: string;
}) => Effect.Effect<{ sessions: SessionSummary[] }, unknown, PluginServices> = Effect.fn("OpenCode.listSessions")(
function* (this: OpenCodeAgent, params: { workspace?: string }) {
const directory = String(params.workspace ?? "");
// `roots` leaves out subagent sessions, which belong to the chat that
// started them.
const sessions = yield* this.get("/session", { directory, roots: "true" });
return {
sessions: (Array.isArray(sessions) ? sessions : [])
.flatMap((session) => {
const record = obj(session);
if (typeof record["id"] !== "string" || typeof record["parentID"] === "string") return [];
const time = obj(record["time"]);
return [
compact<SessionSummary>({
id: record["id"],
title:
typeof record["title"] === "string" && !map.isPlaceholderTitle(record["title"])
? record["title"]
: undefined,
createdAt: map.millis(time["created"]) ?? undefined,
updatedAt: map.millis(time["updated"]) ?? undefined,
}),
];
})
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")),
};
},
);
readSession: (params: {
workspace?: string;
sessionId: string;
}) => Effect.Effect<{ items: TranscriptItem[] }, unknown, PluginServices> = Effect.fn("OpenCode.readSession")(
function* (this: OpenCodeAgent, params: { workspace?: string; sessionId: string }) {
const sessionId = params.sessionId;
const directory = String(params.workspace ?? this.sessions.get(sessionId)?.workspace ?? "");
const sessions = yield* this.history(sessionId, directory);
const items = map.transcriptTree(sessionId, sessions);
this.rememberLiveTasks(sessionId, items);
return { items };
},
);
createSession: (params: SessionParams) => Effect.Effect<{ sessionId: string }, unknown, PluginServices> = Effect.fn(
"OpenCode.createSession",
)(function* (this: OpenCodeAgent, params: SessionParams) {
const directory = String(params.workspace ?? "");
const options = params.options ?? {};
// No title: the server mints a placeholder, which `map` filters out, and
// the real title arrives once the agent names the session.
const body: Record<string, unknown> = {};
if (typeof options[OPTION_AGENT] === "string") body["agent"] = options[OPTION_AGENT];
const model = splitModel(options[OPTION_MODEL]);
if (model) body["model"] = { providerID: model[0], id: model[1] };
// The rules the chat runs under, rather than whatever the chosen agent
// happens to allow.
body["permission"] = permission.ruleset(permissionMode.selected(options));
const created = obj(yield* this.post("/session", { directory }, body));
if (typeof created["id"] !== "string") throw new Error("opencode created a session without an id");
this.sessions.set(created["id"], {
workspace: directory,
options: { ...options },
ranOn: null,
tools: params.tools ?? [],
instructions: params.instructions ?? null,
});
yield* this.ensureStream(directory);
yield* this.offerSessionTools(created["id"]);
return { sessionId: created["id"] };
});
resumeSession: (params: SessionParams & { sessionId: string }) => Effect.Effect<{}, unknown, PluginServices> =
Effect.fn("OpenCode.resumeSession")(function* (this: OpenCodeAgent, params: SessionParams & { sessionId: string }) {
const directory = String(params.workspace ?? "");
const options = params.options ?? {};
// The server keeps sessions; re-attaching is remembering the id and
// listening to the folder again.
const attached = yield* this.get(`/session/${params.sessionId}`, { directory }).pipe(Effect.result);
if (Result.isFailure(attached))
return yield* Effect.fail(
new Error(`opencode does not know session ${params.sessionId}: ${errorMessage(attached.failure)}`),
);
const known: unknown = attached.success;
const previous = this.sessions.get(params.sessionId);
this.sessions.set(params.sessionId, {
workspace: directory,
options: { ...options },
ranOn: previous?.ranOn ?? null,
tools: params.tools ?? [],
instructions: params.instructions ?? null,
});
// The session still carries the rules it was created with, which may
// predate the mode the user has since chosen.
yield* this.pushRules(
params.sessionId,
directory,
permissionMode.selected(options),
obj(known)["permission"] ?? null,
);
yield* this.ensureStream(directory);
yield* this.offerSessionTools(params.sessionId);
return {};
});
closeSession: (params: { sessionId: string }) => Effect.Effect<{}, never, PluginServices> = Effect.fn(
"OpenCode.closeSession",
)(function* (this: OpenCodeAgent, params: { sessionId: string }) {
this.sessions.delete(params.sessionId);
for (const key of this.inputRuns.keys()) if (key.startsWith(`${params.sessionId}/`)) this.inputRuns.delete(key);
for (const [id, child] of [...this.children]) if (child.root === params.sessionId) this.children.delete(id);
return {};
});
prompt: (params: {
sessionId: string;
input?: PromptInput;
}) => Effect.Effect<{ runId: string; receipt?: InputReceipt }, unknown, PluginServices> = Effect.fn(
"OpenCode.prompt",
)(function* (this: OpenCodeAgent, params: { sessionId: string; input?: PromptInput }) {
const sessionId = params.sessionId;
const input = params.input;
if (input?.delivery?.intent === "queue" || this.runs.has(sessionId)) {
const reason =
input?.delivery?.intent === "queue"
? "Queued input must remain held by the host"
: "This OpenCode server has no proven native steering operation; wait for the current turn to finish";
if (input?.delivery) this.emitFor(sessionId, { event: "input_rejected", ...input.delivery, reason });
return yield* Effect.fail(new Error(reason));
}
const state = this.sessionState(sessionId);
const runId = newId("opencode-run");
const nativeInputId = input?.itemId
? messageIdFor(input.itemId)
: input?.delivery
? messageIdFor(input.delivery.attemptId)
: undefined;
const run: RunState = { id: runId, error: null, startedAt: Date.now() };
if (input?.delivery && nativeInputId) run.delivery = { ...input.delivery, nativeInputId, consumed: false };
// Reserve before preparation yields: a second start cannot slip through.
this.runs.set(sessionId, run);
const query = { directory: state.workspace };
const agentOption = state.options[OPTION_AGENT];
const agent = typeof agentOption === "string" ? agentOption : null;
const modelOption = state.options[OPTION_MODEL];
const model = typeof modelOption === "string" ? modelOption : null;
const variantOption = state.options[OPTION_VARIANT];
const variant = typeof variantOption === "string" ? variantOption : null;
const blocks = input?.blocks ?? [];
const agentSelf = this;
let dispatched = false;
let returned = false;
return yield* Effect.gen(function* () {
yield* agentSelf.ensureStream(state.workspace);
// A server started again since the session began forgot the tools.
yield* agentSelf.offerSessionTools(sessionId);
const slash = yield* agentSelf.slashCommandOf(state.workspace, plainText(blocks));
const parts = slash ? null : yield* Effect.try({ try: () => promptParts(blocks), catch: (error) => error });
if (agentSelf.runs.get(sessionId) !== run || run.stopping)
return yield* Effect.fail(new Error("The OpenCode run ended before input could be sent"));
if (run.delivery) agentSelf.inputRuns.set(`${sessionId}/${nativeInputId}`, run);
if (slash) {
// `session.command` answers only when the run is over; the
// transcript arrives on the event stream either way.
const commandBody = compact({
command: slash.name,
arguments: slash.arguments,
agent,
model,
variant,
messageID: nativeInputId,
});
const commandPath = `/session/${sessionId}/command`;
dispatched = true;
yield* Effect.gen(function* () {
const settled = yield* agentSelf.post(commandPath, query, commandBody).pipe(Effect.result);
if (Result.isFailure(settled)) {
if (agentSelf.runs.get(sessionId) !== run) return;
const message = errorMessage(settled.failure);
agentSelf.notice(sessionId, "error", message);
agentSelf.finish(sessionId, outcome.failed(message), run);
return;
}
const info = obj(obj(settled.success)["info"]);
if (info["sessionID"] !== sessionId || info["role"] !== "assistant" || typeof info["id"] !== "string") return;
if (info["error"] != null) {
if (agentSelf.runs.get(sessionId) !== run) return;
const message = map.errorMessage(info["error"]);
agentSelf.notice(sessionId, "error", message);
agentSelf.finish(sessionId, outcome.failed(message), run);
return;
}
if (typeof obj(info["time"])["completed"] !== "number") return;
if (nativeInputId && info["parentID"] !== nativeInputId) return;
agentSelf.consumeInput(sessionId, run);
agentSelf.finish(sessionId, run.error ? outcome.failed(run.error) : outcome.completed(), run);
}).pipe(Effect.forkIn(agentSelf.scope));
} else {
const body: Record<string, unknown> = { parts };
// Preserve the rollback alias; absent a user row, name the attempt.
if (nativeInputId) body["messageID"] = nativeInputId;
if (agent) body["agent"] = agent;
const pair = splitModel(model);
if (pair) body["model"] = { providerID: pair[0], modelID: pair[1] };
if (variant) body["variant"] = variant;
// Plugin rules and the tools guide, on top of OpenCode's own prompt.
if (typeof state.instructions === "string" && state.instructions.trim()) body["system"] = state.instructions;
dispatched = true;
yield* agentSelf.postEmpty(`/session/${sessionId}/prompt_async`, query, body);
}
returned = true;
// Neither the 204 nor starting a command fiber proves model pickup.
return compact({
runId,
receipt: run.delivery ? { evidence: "local_write" as const, nativeInputId } : undefined,
});
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (returned) return;
if (agentSelf.runs.get(sessionId) === run) agentSelf.runs.delete(sessionId);
if (!dispatched && run.delivery) {
const key = `${sessionId}/${run.delivery.nativeInputId}`;
if (agentSelf.inputRuns.get(key) === run) agentSelf.inputRuns.delete(key);
agentSelf.emit({
sessionId,
runId,
event: "input_rejected",
inputId: run.delivery.inputId,
attemptId: run.delivery.attemptId,
reason: "OpenCode could not prepare this input; it was not sent",
});
}
}),
),
);
});
cancel: (params: { sessionId: string }) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn("OpenCode.cancel")(
function* (this: OpenCodeAgent, params: { sessionId: string }) {
const sessionId = params.sessionId;
const state = this.sessionState(sessionId);
const query = { directory: state.workspace };
const run = this.runs.get(sessionId);
if (run) run.stopping = true;
// Its subagents are stopped by the user, not failing: marked first, so
// the abort errors they report do not read as failures.
const subagents = [...this.children].filter(([, child]) => child.root === sessionId).map(([id]) => id);
for (const child of subagents) this.updateChild(child, (known) => known.settle("cancelled"));
// Unblock anything the run or its subagents wait on before aborting.
yield* this.rejectRequests(new Set([...subagents, sessionId]), state.workspace, false);
// The chat stops before its subagents, so it cannot start another one
// while the tree is walked.
let failure: unknown = null;
const settled = yield* Effect.result(this.postEmpty(`/session/${sessionId}/abort`, query, {}));
if (Result.isFailure(settled)) failure = settled.failure;
yield* this.abortChildren(sessionId, state.workspace);
if (run) this.finish(sessionId, outcome.cancelled(), run);
if (failure) throw failure;
return {};
},
);
cancelTask: (params: { sessionId: string; taskId: string }) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn(
"OpenCode.cancelTask",
)(function* (this: OpenCodeAgent, params: { sessionId: string; taskId: string }) {
const sessionId = params.sessionId;
const taskId = params.taskId;
const state = this.sessionState(sessionId);
if (this.children.get(taskId)?.root !== sessionId)
throw new Error(`no opencode subagent ${taskId} in session ${sessionId}`);
// Marked before the abort, so the abort error it reports reads as the
// stop it is. Its own subagents stop with it.
const tree = this.subtree(taskId);
for (const child of tree) this.updateChild(child, (known) => known.settle("cancelled"));
yield* this.rejectRequests(new Set(tree), state.workspace, true);
// The parent's `task` call then fails and the parent turn goes on.
yield* this.postEmpty(`/session/${taskId}/abort`, { directory: state.workspace }, {});
return {};
});
setOption: (params: {
sessionId: string;
optionId: string;
value: unknown;
}) => Effect.Effect<{ options: ConfigOption[] }, unknown, PluginServices> = Effect.fn("OpenCode.setOption")(
function* (this: OpenCodeAgent, params: { sessionId: string; optionId: string; value: unknown }) {
const state = this.sessionState(params.sessionId);
// Variants belong to a model, so a model change drops the choice.
if (params.optionId === OPTION_MODEL) delete state.options[OPTION_VARIANT];
if (params.value === null || params.value === undefined) delete state.options[params.optionId];
else state.options[params.optionId] = params.value;
// The model, agent and variant ride along with each prompt, but the
// permission rules live on the session and have to be sent.
if (params.optionId === permissionMode.OPTION_ID) {
yield* this.pushRules(params.sessionId, state.workspace, permissionMode.selected(state.options), undefined);
}
return { options: yield* this.options(state.workspace, state.options) };
},
);
respondToApproval: (params: { approvalId: string; optionId: string }) => Effect.Effect<{}, unknown, PluginServices> =
Effect.fn("OpenCode.respondToApproval")(function* (
this: OpenCodeAgent,
params: { approvalId: string; optionId: string },
) {
const pending = this.approvals.get(params.approvalId);
if (!pending) throw new Error(`no pending opencode permission ${params.approvalId}`);
if (!["once", "always", "reject"].includes(params.optionId))
throw new Error(`unknown permission reply ${params.optionId}`);
this.approvals.delete(params.approvalId);
this.requestDone(pending.session);
yield* this.postEmpty(
`/permission/${params.approvalId}/reply`,
{ directory: pending.workspace },
{ reply: params.optionId },
);
return {};
});
respondToQuestion: (params: {
questionId: string;
answer?: QuestionInput;
}) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn("OpenCode.respondToQuestion")(function* (
this: OpenCodeAgent,
params: { questionId: string; answer?: QuestionInput },
) {
const pending = this.questions.get(params.questionId);
if (!pending) throw new Error(`no pending opencode question ${params.questionId}`);
this.questions.delete(params.questionId);
this.requestDone(pending.pending.session);
const query = { directory: pending.pending.workspace };
if (params.answer?.cancelled) {
yield* this.postEmpty(`/question/${params.questionId}/reject`, query, {});
return {};
}
// One answer per question, in the order the server asked them; each is
// the list of chosen labels.
const answers = pending.fields.map((field) => fieldAnswer(field, params.answer?.values ?? {}));
yield* this.postEmpty(`/question/${params.questionId}/reply`, query, { answers });
return {};
});
listSkills: (params: { workspace: string }) => Effect.Effect<{ skills: Skill[] }, unknown, PluginServices> =
Effect.fn("OpenCode.listSkills")(function* (this: OpenCodeAgent, params: { workspace: string }) {
const directory = String(params.workspace ?? "");
const listed = yield* this.http("GET", "/skill", { query: { directory }, timeout: SKILLS_TIMEOUT }).pipe(
Effect.result,
);
let entries: unknown;
if (Result.isSuccess(listed)) {
entries = listed.success;
} else {
const error = listed.failure;
// The picker opens on a keystroke, so a server that stalls or fails
// is worked around rather than waited on.
if (this.external) return yield* Effect.fail(error);
console.warn(
`opencode: the server could not list skills (${errorMessage(error)}); asking the command line instead`,
);
const fallback = yield* cli
.skills(command(this.instance, this.env) ?? "opencode", directory)
.pipe(Effect.result);
if (Result.isFailure(fallback))
return yield* Effect.fail(
new Error(`${errorMessage(fallback.failure)} (the opencode server failed first: ${errorMessage(error)})`),
);
entries = fallback.success;
}
const skills: Skill[] = [];
for (const entry of Array.isArray(entries) ? entries : []) {
const record = obj(entry);
if (typeof record["name"] !== "string" || !record["name"]) continue;
skills.push({
name: record["name"],
description: typeof record["description"] === "string" ? record["description"] : "",
source: skillSource(typeof record["location"] === "string" ? record["location"] : "", directory),
// OpenCode lets the model invoke any skill it can see.
manualOnly: false,
});
}
// Every chat of this folder shares the list, so the picker is right
// without waiting for the user to open it again.
for (const [session, state] of this.sessions) {
if (state.workspace === directory) this.emitFor(session, { event: "skills", skills });
}
return { skills };
});
/// Forgets `itemId` and every message after it with `DELETE
/// /session/{id}/message/{messageID}`, not `POST /revert`: revert also
/// undoes file changes, and files belong to Convergence's checkpoints.
rollback: (params: { sessionId: string; itemId: string }) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn(
"OpenCode.rollback",
)(function* (this: OpenCodeAgent, params: { sessionId: string; itemId: string }) {
const sessionId = params.sessionId;
const itemId = params.itemId;
const state = this.sessionState(sessionId);
const query = { directory: state.workspace };
const read = yield* this.get(`/session/${sessionId}/message`, query).pipe(Effect.result);
if (Result.isFailure(read))
return yield* Effect.fail(new Error(`reading the session to rewind: ${errorMessage(read.failure)}`));
const list = Array.isArray(read.success) ? read.success : [];
const named = messageIdFor(itemId);
const from = list.findIndex(
(message) => obj(obj(message)["info"])["id"] === itemId || obj(obj(message)["info"])["id"] === named,
);
if (from < 0) throw new Error(`opencode does not know message ${itemId}`);
// Newest first: deleting an earlier message must not shift the ones
// still to be deleted.
for (const message of list.slice(from).reverse()) {
const id = obj(obj(message)["info"])["id"];
const forgotten = yield* this.delete(`/session/${sessionId}/message/${String(id)}`, query).pipe(Effect.result);
if (Result.isFailure(forgotten))
return yield* Effect.fail(
new Error(`forgetting opencode message ${String(id)}: ${errorMessage(forgotten.failure)}`),
);
}
return {};
});
forkSession: (
params: SessionParams & { sessionId: string },
) => Effect.Effect<{ sessionId: string }, unknown, PluginServices> = Effect.fn("OpenCode.forkSession")(function* (
this: OpenCodeAgent,
params: SessionParams & { sessionId: string },
) {
const sessionId = params.sessionId;
const directory = String(params.workspace ?? "");
const options = params.options ?? {};
const query = { directory };
const before = yield* this.get(`/session/${sessionId}/message`, query);
const made = yield* this.post(`/session/${sessionId}/fork`, query, {}).pipe(Effect.result);
if (Result.isFailure(made))
return yield* Effect.fail(new Error(`forking opencode session ${sessionId}: ${errorMessage(made.failure)}`));
const forked: unknown = made.success;
const record = obj(forked);
if (typeof record["id"] !== "string") throw new Error("opencode answered the fork without a session");
const forkedId = record["id"];
if (forkedId === sessionId) throw new Error(`opencode answered the fork of ${sessionId} with the same session`);
// A fork that lost part of the conversation is worse than no fork: the
// copy would look complete while missing what came before.
const after = yield* this.get(`/session/${forkedId}/message`, query);
const had = Array.isArray(before) ? before.length : 0;
const kept = Array.isArray(after) ? after.length : 0;
if (kept < had)
throw new Error(`opencode forked ${sessionId} into ${forkedId} but kept ${kept} of ${had} messages`);
this.sessions.set(forkedId, {
workspace: directory,
options: { ...options },
ranOn: null,
tools: params.tools ?? [],
instructions: params.instructions ?? null,
});
// A fork inherits the original's rules; the copy may go on under
// another mode.
yield* this.pushRules(forkedId, directory, permissionMode.selected(options), obj(forked)["permission"] ?? null);
yield* this.ensureStream(directory);
yield* this.offerSessionTools(forkedId);
return { sessionId: forkedId };
});
compact: (params: { sessionId: string }) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn(
"OpenCode.compact",
)(function* (this: OpenCodeAgent, params: { sessionId: string }) {
const state = this.sessionState(params.sessionId);
if (this.runs.has(params.sessionId))
throw new Error("opencode cannot summarize the conversation while a run is active");
// Compaction runs a model turn of its own, so it needs the chat's model.
const pair = splitModel(state.options[OPTION_MODEL]);
if (!pair) throw new Error("opencode needs a model before it can summarize the conversation");
const summarized = yield* this.http("POST", `/session/${params.sessionId}/summarize`, {
query: { directory: state.workspace },
body: { providerID: pair[0], modelID: pair[1], auto: false },
empty: true,
timeout: COMPACT_TIMEOUT,
}).pipe(Effect.result);
if (Result.isFailure(summarized))
return yield* Effect.fail(
new Error(`summarizing the opencode conversation: ${errorMessage(summarized.failure)}`),
);
return {};
});
/// OpenCode runs on the user's own provider keys and publishes no
/// subscription window.
usageLimits: (this: OpenCodeAgent) => Effect.Effect<{ limits: null }, unknown, PluginServices> = Effect.fn(
"OpenCode.usageLimits",
)(function* (this: OpenCodeAgent) {
return { limits: null };
});
/// Upgrades the installed OpenCode through the installer that owns it.
update: (this: OpenCodeAgent) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn("OpenCode.update")(function* (
this: OpenCodeAgent,
) {
const { path, owner } = yield* maintenance.locate();
if (!path) throw new client.Trouble("not_installed");
const plan = maintenance.updateCommand(owner);
if (!plan)
throw new Error(
"Divergence cannot prove which installer owns this opencode; upgrade it the way you installed it",
);
const result = yield* runProcess(plan.program, plan.args).pipe(Effect.scoped);
if (result.code !== 0) {
throw new Error(
`${plan.program} ${plan.args.join(" ")} failed with ${result.code ?? result.signal}: ${result.stderr.trim()}`,
);
}
// The running server is the old build.
yield* this.shutdown();
return {};
});
logout: (this: OpenCodeAgent) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn("OpenCode.logout")(function* (
this: OpenCodeAgent,
) {
const read = yield* this.get("/provider", {}).pipe(Effect.result);
if (Result.isFailure(read))
return yield* Effect.fail(new Error(`reading the opencode providers: ${errorMessage(read.failure)}`));
const listed = obj(read.success)["connected"];
const connected = Array.isArray(listed) ? listed : [];
if (!connected.length) throw new Error("opencode is not signed in to any provider");
for (const provider of connected) {
const left = yield* this.delete(`/auth/${String(provider)}`, {}).pipe(Effect.result);
if (Result.isFailure(left))
return yield* Effect.fail(
new Error(`signing out of ${String(provider)} in opencode: ${errorMessage(left.failure)}`),
);
}
this.catalogs.clear();
return {};
});
/// Starts a provider's browser sign-in: `method` is `<provider>` or
/// `<provider>:<method index>`. OpenCode answers with the page to open;
/// it opens in the system browser.
authenticate: (params: { method?: unknown }) => Effect.Effect<{}, unknown, PluginServices> = Effect.fn(
"OpenCode.authenticate",
)(function* (this: OpenCodeAgent, params: { method?: unknown }) {
const text = String(params.method ?? "");
const colon = text.indexOf(":");
const provider = colon < 0 ? text : text.slice(0, colon);
const asked = colon < 0 ? null : Number.parseInt(text.slice(colon + 1), 10);
const all = obj(yield* this.get("/provider/auth", {}));
const raw = all[provider];
const methods = Array.isArray(raw) ? raw : null;
if (!methods) throw new Error(`opencode has no sign-in methods for ${provider}`);
const index =
typeof asked === "number" && Number.isInteger(asked)
? asked
: methods.findIndex((entry) => obj(entry)["type"] === "oauth");
if (index < 0) throw new Error(`${provider} has no browser login; run \`opencode auth login\` to enter an API key`);
const rawChosen = methods[index];
if (rawChosen === null || rawChosen === undefined) throw new Error(`${provider} has no sign-in method ${index}`);
const chosen = obj(rawChosen);
if (chosen["type"] !== "oauth")
throw new Error(`${provider} login "${String(chosen["label"])}" needs an API key; run \`opencode auth login\``);
const authorization = obj(yield* this.post(`/provider/${provider}/oauth/authorize`, {}, { method: index }));
const url = typeof authorization["url"] === "string" ? authorization["url"] : "";
if (!url) throw new Error(`opencode did not answer with a sign-in page for ${provider}`);
// The runtime opens it while this sign-in runs, and says when it
// could not.
const kernel = yield* Kernel;
const openedLink = yield* kernel.call("open_url", { url }).pipe(Effect.result);
if (Result.isFailure(openedLink))
return yield* Effect.fail(
new Error(`open ${url} in your browser to sign in to ${provider} (${errorMessage(openedLink.failure)})`),
);
const opened = obj(openedLink.success);
if (opened["opened"] === false) {
throw new Error(
`open ${url} in your browser to sign in to ${provider} (${String(opened["reason"] ?? "the link was not opened")})`,
);
}
if (authorization["method"] === "code") {
throw new Error(
`${provider} login opened in the browser but returns a code to paste, which Divergence cannot collect yet; ` +
`finish with \`opencode auth login\`. ${String(authorization["instructions"] ?? "")}`.trim(),
);
}
this.catalogs.clear();
return {};
});
}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.