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

NOTES.md22.7 KB
# OpenCode provider plugin — decisions

Ground truth for every shape used here is the OpenAPI document of the
running server (`GET /doc`, opencode **1.18.29**) plus live captures of
`GET /event` taken while writing this plugin.

The plugin is JavaScript (`main.js`), sandboxed like every plugin: the
server is a child started through `api.process`, every HTTP call and the
event stream go through `api.net` (the `localhost:*` grant). It replaced
a Rust program, deleted with the other native providers. The modules:

| File | What it holds |
| --- | --- |
| `main.js` | registration: the local agent at once, stored servers after (the runtime tells the host), the login environment |
| `legacy.js` | the Rust plugin's `instances.json`, read once into storage |
| `agent.js` | the agent: server, event stream, runs, subagents, approvals, every `agent/*` handler |
| `client.js` | the `opencode serve` child (`LocalServer`), requests with Basic credentials, versions, the four troubles |
| `map.js` | events and history to agent events and transcript items (pure) |
| `options.js`, `prompt.js`, `permission.js` | options from the live catalog, prompt parts, the permission ruleset |
| `cli.js` | the command line as a second source when the server cannot answer |
| `maintenance.js`, `instances.js` | installer ownership and upgrades; the configured servers |

## Transport

`opencode serve --hostname 127.0.0.1 --port <port>`, one child per local
agent, started lazily on the first call. The listen URL is read from the
`opencode server listening on http://127.0.0.1:<port>` line on stdout and
then confirmed with `GET /global/health` (which also gives the version
shown in `AgentInfo`). Both pipes are drained for as long as the child
runs, so it never blocks on a full one. The broker runs the child in a
process group of its own and kills the group when the plugin unloads;
`update` stops it too.

One SSE subscription per **workspace directory** (`GET /event?directory=…`),
started on `create_session` / `resume_session` / `prompt`. If a stream ends
or errors, every run of that workspace is failed with the reason and the
directory is unsubscribed, so the next prompt re-subscribes.

## Why the v1 session family, not `/api/session` (v2)

The v2 family (`v2.session.*`, `permission.v2.*`, `session.next.*` events)
exists on this server and is nicer, but **it is broken on this machine's
data directory**: `POST /api/session/{id}/prompt` answers `500` with
`SQLiteError: no such table: session_input`, and `/api/session/{id}/model`
fails with `FOREIGN KEY constraint failed`. The user's
`~/.local/share/opencode/opencode.db` is on the older schema generation
(`session_v2`, `session_inbox`, `session_pending`) and 1.18.29 does not
migrate it; a freshly created data directory gets `session_input` and works.
Because the plugin must run against the user's real installation, it uses
the v1 endpoints, which work on both schema generations:

| Purpose | Endpoint |
| --- | --- |
| health / version | `GET /global/health` |
| model catalog (with variants) | `GET /config/providers?directory=` |
| modes / agents | `GET /agent?directory=` |
| slash commands | `GET /command?directory=` |
| create session | `POST /session?directory=` `{title:"New Chat", agent?, model?}` |
| list sessions | `GET /session?directory=&roots=true` (subagent sessions left out) |
| re-attach | `GET /session/{id}?directory=` |
| history | `GET /session/{id}/message?directory=`, plus `GET /session/{id}/children` and each child's messages |
| stop one subagent | `POST /session/{child}/abort?directory=` |
| prompt | `POST /session/{id}/prompt_async?directory=` `{parts, model:{providerID,modelID}, agent, variant}` |
| slash command | `POST /session/{id}/command?directory=` `{command, arguments, agent, model, variant}` |
| cancel | `POST /session/{id}/abort?directory=` |
| events | `GET /event?directory=` (SSE) |
| permission reply | `POST /permission/{id}/reply?directory=` `{reply: once\|always\|reject}` |
| question reply / reject | `POST /question/{id}/reply` `{answers:[[label,…]]}` · `POST /question/{id}/reject` |
| login | `GET /provider/auth` · `POST /provider/{id}/oauth/authorize` `{method:<index>}` |

If the v2 store is fixed upstream, moving over means swapping the six
session calls and reading `session.next.*` events instead of
`message.part.*`; the mapping in `map.js` is the only other place to touch.

## Event mapping

* `message.part.delta` (`field: text|reasoning`) → `TextDelta`/
  `ReasoningDelta` with `mode: Append`, keyed by `partID`. Deltas are
  forwarded byte for byte.
* `message.part.updated` for a text/reasoning part carries the whole text
  of the part, so it is forwarded with `mode: Replace` on the same item id.
* Parts of the **user** message are dropped (the server replays prompt text
  and tool results as `synthetic` user parts; `message.updated` tells us
  which message ids are the user's).
* `message.part.updated` for a tool part → `ToolCallStarted` the first time
  that part id is seen, `ToolCallUpdated` afterwards. The kind comes from
  the structured `part.tool` name only — command strings are never parsed:
  `read`→Read, `edit|write|patch|multiedit`→Edit, `bash|shell`→Execute,
  `grep|glob|list`→Search, `webfetch`→Fetch, `task`→Task,
  `todowrite|todoread`→Think, everything else (including MCP tools)→Other.
* Commands carry `ToolContent::Terminal` built from `state.metadata.output`
  and `state.metadata.exit`. OpenCode streams **cumulative** output, so the
  content is replaced on each update rather than appended with
  `output_delta`.
* Edits carry `ToolContent::Diff { diff }` from `state.metadata.diff`, which
  is a unified patch; `write` falls back to `new_text` from `input.content`.
* `step-finish` parts → `Usage` (`tokens.total`, `cost`). The context window
  is filled in by the agent from the catalog entry of the model the last
  assistant message ran on (`message.updated` `providerID`/`modelID`), else
  the chosen model option. A chat that never picked a model runs the
  server's default, which the options do not name.
* `todo.updated` → `Plan`. The `todowrite` tool itself stays an ordinary
  tool row, so the plan is not emitted twice.
* `session.error` → `Notice{Error}` and is remembered on the run;
  `session.idle` ends the run as `Failed` when an error was seen and
  `Completed` otherwise. `cancel` ends it as `Cancelled` and removes the
  run, so the later `session.idle` is a no-op — exactly one `RunFinished`
  per run in every path, including a dead event stream.

## Subagents

OpenCode runs a subagent (the `task` tool) as a session of its own with
`parentID` set. Verified against 1.18.29 (`src/tool/task.ts` and live runs):

* The child session is created first (`session.created` with `parentID`,
  title `<description> (@<agent> subagent)`), then the `task` part gets
  `state.metadata = {parentSessionId, sessionId: <child>, model:
  {providerID, modelID}, background?: true}` through `ctx.metadata`, so the
  **running** part already names the child. The completed part keeps it.
* The task id is the child session id. `tool_call_id` is the `task` part's
  id (the id of the tool row in the transcript), so the UI hides that row.
  Title: the task `description` (the session title with the suffix
  stripped as a fallback, then the prompt's first line); never a tool name.
  `name` = `subagent_type`, `prompt` = the tool input `prompt`, `model` =
  `metadata.model.modelID`, `effort` = the child's assistant `variant`.
* Every event of a child session is sent on the chat's session with
  `taskId` = the child. A grandchild's `parentTaskId` is the child that
  started it; the chain is followed to the chat for routing.
* Status: child `session.idle` → completed; `session.error` → failed;
  the `task` part's own outcome wins when it arrives (`Task cancelled` or an
  aborted tool → cancelled). A pending permission or question of the child
  → waiting until it is answered. A resumed task (`task_id` input) sets the
  same subagent running again.
* `summary` is the task output without its wrapper:
  `<task id=".." state="completed|error|running"><summary>?</summary>
  <task_result|task_error>TEXT</..></task>`; before that, the child's last
  text. `activity` is the title of the child's latest running tool;
  `toolUses` counts its tool parts.
* Usage: the child's step usage is never forwarded as a `usage` event (it
  would land on the task and be overwritten by the snapshot); it is folded
  into `TaskInfo.usage`: `usedTokens` = the latest step's context (Claude's
  meaning too), `costUsd` summed over steps. Steps are de-duplicated by
  part id.
* **Background** subagents (`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`):
  the part completes at once with a `state="running"` report; the result
  comes later as a **synthetic user text** in the parent session with the
  same wrapper. That text is mapped to the task's end, not shown.
* `cancel_task` marks the subtree cancelled first (so the abort error the
  child reports is not read as a failure), rejects its pending requests,
  then `POST /session/{child}/abort`. The parent's `task` call then errors
  and the parent turn continues. `cancel` on the chat marks and stops every
  subagent and rejects their requests as well.
* History: `read_session` reads the chat's messages, then for every
  session the `task` parts link (and every `GET /session/{id}/children`
  entry) its messages, recursively. Each subagent becomes a `task` item
  right after the `task` tool item, with its own transcript inside; children
  that no call names are appended at the end. A resumed subagent is shown
  once, under its first call. Subagents history shows still running are
  remembered so their live events route to the chat and they can be
  stopped.
* The v2 `/api/session` family has no subagent concept; the v1 routes are
  used.

## Approvals and questions

`permission.asked` blocks the tool **on the server**, so the plugin does not
keep oneshot senders: it forwards the request and posts the reply when the
host answers. Options are the native ones (`once`, `always`, `reject`);
`always` is offered only when the request carries `always` patterns.
`cancel` rejects every pending permission and question of the session first,
so the native side unblocks before the abort.

`question.asked` maps each question to one `QuestionField` (`Select`, or
`MultiSelect` when `multiple`), `allow_other` from `custom`, choices keyed
by their label because `POST /question/{id}/reply` answers with labels.
Answers are ordered by field index, which is the order the server asked.

## Options

* **Model** (`model`), value `"<providerID>/<modelID>"`, grouped by provider
  name, from `GET /config/providers` — the same catalog `prompt_async`
  accepts. (`GET /api/model` lists more models but reports `variants: []`
  on this version, so it is not usable for reasoning levels.)
* **Reasoning levels** come from each model's `variants` map and are
  attached to the model choice; a separate `variant` option (category
  `Reasoning`) stays in the catalog whenever any model has variants. The
  composer shows it only while the chosen model has variants, and its
  value is `null` until the user picks one, which means "the provider
  default". Changing the model clears the stored variant.
* Variants are ordered `none, minimal, low, medium, high, xhigh, max`, then
  unknown names alphabetically. The names themselves always come from the
  API; only their display order is ranked, because a JSON object has no
  order once parsed.
* **Mode** (`agent`) from `GET /agent`, filtered to non-hidden `primary`/
  `all` agents (build, plan …).
* There are no hardcoded catalogs anywhere: if the catalog call fails,
  `list_options` returns the error, and a workspace with no connected
  provider returns "run `opencode auth login` first".
* The initial model value is the `default` entry of the first provider that
  has one. OpenCode exposes no "last used model" over HTTP, so after the
  user picks one the host's stored value is what matters.
* `set_option` only records the value; the model, agent and variant are sent
  with every `prompt_async`, so nothing has to be switched server-side and
  two chats in one workspace can use different models.

## Prompt input

Text blocks become `text` parts. Images become a `file` part with a
`data:` URL. `@path` mentions become a `file` part with a `file://` URL —
the server reads the file and injects its content, which is what a mention
means in OpenCode (verified live).

A prompt whose text starts with `/name`, where `name` is in
`GET /command`, is sent to `POST /session/{id}/command` instead. That call
answers only when the run is over, so it is spawned; failures surface as a
`Notice` plus `RunFinished{Failed}`.

### Durable delivery evidence on the legacy path

`delivery.inputId`/`attemptId` are retained with the originating run. Both
prompt and command requests name their native user message with the existing
`msg_<itemId>` rollback alias, or `msg_<attemptId>` when no user-row id exists.
The async prompt response (and scheduling a command request) supplies only a
`local_write` receipt, never a consumption acknowledgement.

Pickup is established by a native assistant `message.updated` whose
`sessionID` and `parentID` match that exact user-message alias. A user-message
echo, unrelated assistant, text match, or `session.idle` alone is insufficient.
The synchronous command response can provide the same evidence when its
assistant info has matching session/parent identity, numeric `time.completed`
and no native error. It resolves the input before finishing the run, even if
the idle event was lost. Duplicate evidence is emitted once; late evidence
uses the original run id and cannot complete a successor. A cancelled run or
an aborted/error snapshot cannot manufacture consumption.

Preparation reserves the run before yielding and releases only that reservation
on failure/defect, reporting definite unsent input as rejected. Once a native
request was attempted, transport failure is ambiguous, not rejection. The
in-memory alias table retains uncertain deliveries for late native evidence;
it is cleared on session close/provider shutdown. This provider does not
reconstruct the table on restart or scan native history for lost pickup events:
missing evidence stays unknown and must not be automatically replayed. Queues
remain host-owned and active steering remains unsupported, including while a
start is still being prepared.

Evidence checked against the published **1.18.29** source: `session/prompt.ts`
creates the user with `input.messageID`, selects `lastUser.id` as assistant
`parentID`, and passes `CommandInput.messageID` through to the synchronous
prompt loop. `server/routes/instance/httpapi/groups/session.ts` declares a
204/no-content async response and a `SessionV1.WithParts` command response.
Regression tests exercise these native shapes through the scripted HTTP/SSE
server, including delayed replies; this change was not live-tested on a 1.x CLI.

## Login

`authenticate("<providerID>")` (or `"<providerID>:<methodIndex>"`) asks
`GET /provider/auth`, calls `POST /provider/{id}/oauth/authorize` and opens
the returned URL in the system browser. When the provider's flow returns a
code to paste (`method: "code"`) the call reports that Divergence cannot
collect the code yet and the user should finish with `opencode auth login`;
API-key methods report the same, since there is no key prompt in the app.
The page opens with `api.openUrl` (the system default browser). A
provider has no view, so there is no click to count as the user's action;
the runtime opens a web link for a plugin with `agents.provide` while the
user signs in to one of its agents, which is what `authenticate` is. A link
the runtime still keeps closed resolves `{ opened: false, reason }`, and the
sign-in fails with that reason and the page's address.

## Servers (instances)

The plugin serves one agent per OpenCode server. The local one it starts
itself is always `opencode`; extra servers are described in the plugin's
storage, key `instances`. The Rust version read
`<data dir>/agents/opencode/instances.json`; the first time this plugin
loads in its place the host moves that folder into the plugin's data
folder (`legacy/`), and `legacy.js` adds its entries to the stored ones
once (`fs.read` of the `data` scope):

```json
[{ "id": "team", "name": "OpenCode (team server)", "serverUrl": "https://opencode.example:4096" }]
```

Each entry adds `opencode:<id>`. Sessions live on the server, so
`continuation_key` is the server URL: two agents pointing at one server can
continue each other's chats, and two pointing at different servers cannot.

**A local password is never sent to a server somebody else runs.** An
instance carries its own password, and that is the only credential used for
it. `OPENCODE_SERVER_PASSWORD` applies to the local server alone. A server
this plugin starts always gets a password: the configured one, or one made
up for that launch, because OpenCode 1.18 refuses every request to a server
started without one.

**Which binary.** A local server starts from `"command"` in the instance,
else `OPENCODE_PATH` (default agent only), else the first `opencode` on the
login `PATH`. The JavaScript plugin's `process` grant names the bare
program `opencode` only, so a binary path (the first two) is refused by
the broker with a message that says so: until a grant can name a path the
user chose, the 1.x binary has to come first on the login PATH. The override matters because two OpenCodes can be installed at
once: `@opencode/cli` 2.x from npm lands in `~/.local/bin`, ahead of the
`~/.opencode/bin` install, and 2.x serves a different HTTP API (everything
under `/api`, described by `/openapi.json`; `/global/health` returns the web
page). The plugin speaks 1.x and reads `opencode --version` before starting a
server, so a 2.x binary is reported by path and version with the way out,
instead of a health check that only says the answer was not JSON. OpenCode
2.x has its own provider, `plugins/opencode-v2`; this one ships turned off
(`disabledByDefault`).

```json
[{ "id": "", "command": "~/.opencode/bin/opencode" }]
```

The plugin also chooses the server's port itself. `--port 0` is not an
ephemeral port any more: 1.18 reports its default 4096 whatever was asked,
and the address the server reports is checked against the one requested.
A sandboxed plugin cannot bind a port to test it, so the port is random in
20000–49999, and a start that finds it taken (or reported another one)
tries again, three times at most. The server's password is made up per
launch from `crypto.getRandomValues`.

## Server lifecycle

The local server is started on demand and restarted when it dies. It is
spawned in its own process group and stopped with `SIGTERM`, then `SIGKILL`
after a second, because `opencode serve` forks workers that a single kill
would orphan. The listen address is parsed anchored on the scheme and
bounded, so a line that merely mentions a URL is not mistaken for it. The
reported version is checked against 1.14.19, and both startup and the health
check have timeouts that keep the child's output in the failure message.

## Reconnecting

`GET /event` is supervised: a dropped stream reconnects with backoff rather
than failing the runs, because a transport blip is not the end of a run.
Only a server that has really gone ends them. On every reconnect the pending
requests are re-read from `GET /permission` and `GET /question`: anything
asked while we were away is announced, and anything the server has since
forgotten is withdrawn, so no card waits for an answer that can never come.

## Permissions

The four shared modes are pushed as a session ruleset with `session.update`
at create, resume and fork. OpenCode has no approval reviewer, so **Auto is
not offered at all** rather than being made a synonym of Supervised: a mode
that quietly allows more than the user picked is worse than a missing one.
Automatic full-access replies use `once`, so a shared external server's
stored grants are never widened.

## Not implemented on purpose

* `session.revert` / `session.unrevert`: Divergence owns revert (its
  checkpoints). `rollback` forgets messages with `DELETE
  /session/{id}/message/{messageID}` instead, which leaves the files alone;
  a prompt names its message `msg_<host item id>` so the host's id finds it.
* `steer` is `false`; `prompt_async` accepts a `delivery: steer` in v2 only,
  and the v1 path queues instead of steering.
* **No usage limits.** OpenCode runs against the user's own provider keys
  and publishes no subscription window, so there is nothing to report.
* No bundled catalog of any kind. Models, agents, variants and commands all
  come from the live API, with the CLI as a second *source* when the server
  cannot answer — never as an invented list. An empty catalog is an error.

## Plugin tools and instructions

OpenCode takes extra tools only as MCP servers, and a sandboxed plugin
cannot listen, so the host serves them: `host/mcp.serve { agentId,
workspace, tools }` answers a loopback URL, and the plugin adds it to
OpenCode for the folder (`POST /mcp?directory=`, `{ name: "convergence",
config: { type: "remote", url, enabled: true, oauth: false } }`). OpenCode
names each tool `convergence_<tool>`; the row keeps that name and shows
the tool's own as its title. Its permission is the tool's name, so
Supervised asks (the `*` rule) and Full access allows it.

One server per folder, not per session: OpenCode's MCP servers belong to
the folder's instance, and every chat of a folder gets the same tools.
The host tells the calling chat apart by its running turn. The tool list
is compared on every create, resume, fork and prompt; a change (or a new
server process, which forgets dynamic MCP servers) adds the server again,
because OpenCode lists a server's tools once, when it connects. A server
on another computer cannot reach this one's loopback, so a remote instance
gets no tools and the chat is told. Failures to connect are a warning
notice in the chat; the session still starts.

The session's `instructions` (plugin rules, the code mode guide) go with
every prompt as `system`, which OpenCode adds to its own system prompt.

## Command line output through a pipe

The CLI fallback (`models --verbose`, `agent list`, `debug skill`) reads
the child's stdout through the broker's pipe; the Rust version wrote it to
a temp file, which the plugin cannot. OpenCode (Bun) flushes one 64 KiB
buffer to a pipe and then stops when it exits: `debug skill` arrives cut
at exactly 65536 bytes. Output of a whole multiple of 64 KiB that does not
parse is reported as cut rather than read as an empty catalog.

## Tests

`node --test plugins/opencode/*.test.mjs` runs the JavaScript plugin's
tests: mapping fixtures captured from a real server, and the agent against
a scripted server behind `api.net.fetch` (`testing.mjs`). The live test is
`cargo test -p convergence-host --test e2e opencode_calls_a_plugin_tool --
--ignored`: one turn on `opencode/big-pickle` that calls a plugin tool
through the host's MCP server, then reads the session back.

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.