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

main.test.ts5.6 KB
// Scripted scenarios for the opencode conversion.
import { test } from "node:test";
import assert from "node:assert/strict";
import { runPlugin } from "convergence/effect";
import type { OpenCodeAgent } from "./agent.ts";
import { registerServers } from "./main.ts";
import { fakeApi, settle } from "../sdk/testing.ts";
import { obj } from "./map.ts";

function present<T>(value: T | null | undefined): T {
  assert.ok(value != null);
  return value;
}

test("the local server's agent registers at once, stored servers after", async (t) => {
  const api = fakeApi({
    env: { HOME: "/Users/me", OPENCODE_SERVER_PASSWORD: "ambient" },
    onHost: (method, params) => {
      if (method === "host/storage.get") {
        assert.equal(params.key, "instances");
        return {
          value: [
            { id: "", name: "My OpenCode" },
            { id: "team", name: "OpenCode (team)", serverUrl: "https://oc.example:4096/", password: "team-secret" },
          ],
        };
      }
      return {};
    },
  });
  const agents: OpenCodeAgent[] = [];
  const dispose = runPlugin(registerServers(agents))(api);
  t.after(dispose);
  assert.deepEqual(
    api.registered.map((def) => def.id),
    ["opencode"],
    "registered before anything is awaited",
  );
  await settle();
  assert.deepEqual(
    api.registered.map((def) => [def.id, def.name]),
    [
      ["opencode", "OpenCode"],
      ["opencode:team", "OpenCode (team)"],
    ],
  );
  // The runtime tells the host of agents registered after the load.
  assert.equal(
    api.hostCalls.some((call) => call.method === "host/agents.changed"),
    false,
  );

  // The environment and the stored settings reach an agent before its
  // first call.
  const team = present(agents[1]);
  const seen: { url: string; init: unknown }[] = [];
  api.net.fetch = async (url, init) => {
    seen.push({ url, init });
    throw new Error("stop here");
  };
  const definition = present(api.registered[1]);
  const info = await present(definition.initialize)(
    { agentId: definition.id },
    { agentId: definition.id, emit: () => {} },
  );
  assert.equal(info.status?.state, "unavailable");
  assert.ok(seen[0]?.url.startsWith("https://oc.example:4096/global/health"));
  assert.equal(
    obj(obj(seen[0]?.init)["headers"])["authorization"],
    `Basic ${Buffer.from("opencode:team-secret").toString("base64")}`,
    "the instance's own password, never the local one",
  );
  assert.equal(team.env.HOME, "/Users/me");
  api.process.spawn = async () => {
    throw new Error("opencode is not on the PATH");
  };
  const local = present(api.registered[0]);
  await assert.rejects(
    Promise.resolve(
      present(local.list_sessions)({ agentId: local.id, workspace: "/w" }, { agentId: local.id, emit: () => {} }),
    ),
    /not found on PATH; install opencode/,
  );
  assert.equal(present(agents[0]).name, "My OpenCode", "the stored entry without an id names the local agent");

  // Events an agent emits go out under its own id.
  team.emit({ sessionId: "s", event: "run_finished", outcome: { status: "completed" } });
  assert.equal(present(api.emitted.at(-1))["agentId"], "opencode:team");
});

test("without stored servers only the local one is served", async (t) => {
  const api = fakeApi({ onHost: () => ({ value: null }) });
  const dispose = runPlugin(registerServers())(api);
  t.after(dispose);
  await settle();
  assert.deepEqual(
    api.registered.map((def) => def.id),
    ["opencode"],
  );
  assert.equal(
    api.hostCalls.some((call) => call.method === "host/agents.changed"),
    false,
  );
});

// The `binaryPath` setting (MARKETPLACE.md §15.3, STATUS gap 14): the
// binary the user chose runs instead of the first one the login PATH finds,
// and a later change applies to the next launch.
test("the binary from Settings runs the local server", async (t) => {
  const api = fakeApi({
    onHost: () => ({ value: [{ id: "", command: "/usr/local/bin/opencode-stored" }] }),
    settings: { binaryPath: "/opt/homebrew/bin/opencode" },
  });
  const spawned: string[] = [];
  api.process.spawn = async (program) => {
    spawned.push(program);
    throw new Error("stop here");
  };
  const collected: OpenCodeAgent[] = [];
  const dispose = runPlugin(registerServers(collected))(api);
  t.after(dispose);
  await settle();
  const local = present(api.registered[0]);
  await Promise.resolve(
    present(local.list_sessions)({ agentId: local.id, workspace: "/w" }, { agentId: local.id, emit: () => {} }),
  ).catch(() => {});
  assert.equal(spawned[0], "/opt/homebrew/bin/opencode", "the setting comes before a stored binary and the PATH");
  api.changeSettings({ binaryPath: null });
  await settle();
  assert.equal(
    present(collected[0]).instance.command,
    "/usr/local/bin/opencode-stored",
    "cleared, the stored one again",
  );
  api.changeSettings({ binaryPath: "/Users/me/.opencode/bin/opencode" });
  await settle();
  assert.equal(present(collected[0]).instance.command, "/Users/me/.opencode/bin/opencode");
});

// The `serverUrl` setting: its card grants the connection (the manifest's
// `${settings.serverUrl}` net scope), so the external server it names is
// the one the default agent talks to.
test("the external server from Settings is the default agent's server", async (t) => {
  const api = fakeApi({ onHost: () => ({ value: [] }), settings: { serverUrl: "https://oc.example.com:4096" } });
  const servers: OpenCodeAgent[] = [];
  const dispose = runPlugin(registerServers(servers))(api);
  t.after(dispose);
  await settle();
  assert.equal(present(servers[0]).external, "https://oc.example.com:4096");
  api.changeSettings({ serverUrl: null });
  await settle();
  assert.equal(present(servers[0]).external, null, "cleared, a local server again");
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.