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

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${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.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.