Official
acp
ACP agents from the ACP registry (Gemini, Cursor, Droid, Kilo, pi, ...), Oh My Pi, and your own entries (custom.json in the plugin's data folder). Agents are discovered at runtime.
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/acp@0.2.0
Permissions in 0.2.0
Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
Files
NOTES.md23.5 KB
# ACP provider plugin — decisions and protocol notes
Backend: any agent that speaks the **Agent Client Protocol** over stdio
(newline JSON-RPC 2.0). One Divergence agent per ACP registry entry, id
`acp:<registry id>`. Every wire name below was checked against the ACP
schema (`schema.json` of agentclientprotocol/typescript-sdk) and the
registry (`https://cdn.agentclientprotocol.com/registry/v1/latest/registry.tson`).
The plugin is a sandboxed TypeScript + Effect plugin (`main.ts`), like the Codex,
Claude Code and OpenCode ports. It replaced a Rust program, deleted with
the other native providers.
## Layout
| File | Role |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `main.ts` | scoped Effect entry point and shared launch context |
| `plugin.ts` | `AcpPlugin`: reads the registry, marks and custom entries, registers one `AcpAgent` per entry, refreshes the registry once a day |
| `registry.ts` | registry fetch and cache, `KNOWN` agents, custom entries, `resolveLaunch`, marks |
| `agent.ts` | `AcpAgent`: the process, sessions, runs, and every client method the agent calls |
| `wire.ts`, `types.ts` | ACP boundary schemas and provider session state |
| `map.ts` | pure ACP-to-protocol mapping, plus the `Recorder` for replays |
| `quirks.ts` | everything known about one particular agent (see Quirks) |
| `files.ts` | which paths `fs/*` may reach, and the line slice of `fs/read_text_file` |
| `terminal.ts` | the client side of `terminal/*` |
| `throttle.ts` | coalescing of tool-call updates, bounded tool output |
| `icons.ts`, `icons/omp.svg` | the mark of Oh My Pi, which the registry does not list |
The SDK (`../sdk/effect.ts`) gives the scoped JSON-RPC process transport
(`RpcTransport`), typed requests and process execution. The provider uses
`Process.which` for launch resolution and the SDK's shared permission modes.
## Permissions
`plugin.json` asks for exactly what the plugin uses:
- `agents.provide`: the agents, and the host's loopback MCP server for
plugin tools.
- `process.any`: the registry launches agents by path or through package
runners (`npx -y <package>`, `uvx <package>`), custom entries name any
command, `terminal/create` runs whatever the agent asks for, and the
platform comes from `uname`. This makes the plugin dangerous; that is
expected of an official plugin.
- `net` `cdn.agentclientprotocol.com`: the registry and the marks.
- `fs.read` and `fs.write`, `workspace` and `data`: `fs/*` inside the
chat's workspace, and `custom.json` in the plugin's data folder (read on
every start, written once to take over the Rust plugin's entries).
- `env` `XAI_API_KEY` (Grok's credential) and `PROCESSOR_ARCHITECTURE`
(the Windows build of an agent).
## Which agents exist
`start()` reads the cached registry (plugin storage, key `registry`, with
`fetchedAt`), drops the three ids Divergence serves natively
(`opencode`, `claude-acp`, `codex-acp`), adds the `KNOWN` agents the
registry does not list (Oh My Pi: `omp acp`, id `omp`, install hint
`brew install omp`), and adds the user's own entries. Later sources
replace earlier ones by id, so a custom entry can override a known or
registry agent. The list is sorted by name.
A cache younger than a day is used as it is; an older one is used at once
and refreshed in the background; with none, the first start waits for
the network (10 s) and, without it, serves only the known and custom
agents. An hourly check refreshes a day-old registry while the plugin
runs: the agents it adds are registered, the registry agents it no longer
lists are unregistered (`api.agents.unregister`), and the runtime tells
the host each time.
The agents are registered after `activate` returns (the list needs the
plugin's storage), so `contributes.agents` is empty; the runtime tells the
host when the list changes (`host/agents.changed` on the wire), and the
host lists the agents again.
Custom entries, `[{ "id", "name", "command", "args", "env",
"directories", "mcpServers" }]`, come from `custom.json` in the plugin's
data folder (`api.paths.data`, `<data>/plugin-data/convergence__acp/`)
and then from plugin storage (key `custom`, for a settings page); an
entry in storage replaces one of the same id in the file. They are read
when the plugin starts and on the daily refresh: edit the file, then
reload the plugin. The Rust plugin read `<data>/agents/acp/custom.json`;
the host moves that folder into the plugin's data folder as `legacy/` the
first time this plugin loads, and the plugin copies `legacy/custom.json`
to `custom.json` once (unless the user already wrote one; storage key
`legacy` records that it was done).
## Marks
Every registry entry carries an `icon`: a URL of a small SVG on the same
CDN, almost always drawn with `fill="currentColor"`, which the kernel
recolours per theme. The UI takes inline SVG, not URLs, so the plugin
downloads each mark once into plugin storage (key `icons`).
`start()` waits for the ones it is missing, all fetched together and
bounded by 5 s for the set: about a second on a first start, nothing on
every start after it. The wait is deliberate: the host remembers what an
agent answered at `initialize` and only asks again on its re-probe, so a
mark downloaded in the background lands too late and leaves that agent
with the generic logo in between. The daily refresh reads every mark
again, because the registry is republished under one URL. Anything that
is not an SVG of at most 64 KiB is dropped.
The rasterizer behind `img()` is usvg, whose colour parser is SVG's own:
hex, `rgb()`, `hsl()` and the named colours. A CSS Color 4 function such
as `oklch()` does not parse and the shape comes out black, so a mark
added here states its colours in hex.
## Launching
The registry is a **launch contract**, not a command: a per-platform
archive plus `cmd`/`args`, and/or an `npx`/`uvx` package. Divergence does
not download or unpack archives. `resolveLaunch()` looks for the binary
on the login `PATH` (`api.process.which`), then for the package's own
CLI, then falls back to `npx -y <package>` / `uvx <package>`. An agent
published through npx therefore always resolves when `npx` exists, even
when it is not installed: the first launch downloads it. An agent with a
binary-only distribution that is not installed reports `unavailable` with
the archive URL as the install hint. The launch is resolved again
whenever no process runs, so an agent installed since is found.
The platform key comes from `uname -sm` (`darwin-aarch64`, ...); where
there is no `uname` it is Windows, the architecture from
`PROCESSOR_ARCHITECTURE`.
The process starts in the chat's workspace (`cwd`): an ACP agent resolves
relative paths and finds its project configuration from there. What it
writes outside the protocol (a stdout line that is not JSON, every stderr
line) is kept as a tail of 20 lines and 2 KiB, which explains an exit
("Kilo exited with code 1: ..."); the first 32 KiB are also read live for
a sign-in URL (Antigravity).
## Handshake
`initialize` sends `protocolVersion: 1`, `clientCapabilities
{ fs: { readTextFile, writeTextFile }, terminal, elicitation: { form, url },
auth: { terminal: true } }` and `clientInfo { name: "convergence",
title: "Divergence", version }`.
For registry id `cursor` **only**, `clientCapabilities._meta
.parameterizedModelPicker = true` is added (same rule as Zed's
`crates/agent_servers/src/acp.rs`). Without it Cursor bakes every
parameter into the model id (`composer-2.5[fast=true]`) instead of
publishing separate `thought_level` / `model_config` options.
`agentCapabilities` map onto `AgentInfo.capabilities`: `loadSession` ->
`sessionHistory` and `resume`, `sessionCapabilities.resume` -> `resume`,
`sessionCapabilities.list` -> `sessionList`, `sessionCapabilities.fork`
-> `fork`, `promptCapabilities.image` -> `images`. `reasoning`,
`approvals`, `questions`, `slashCommands` and `cancel` are always true:
they are part of the protocol, not optional capabilities. `steer` is
false: ACP has no steering method and forbids a second concurrent prompt.
`agentInfo.title` and `version` replace the registry's name and version.
`_meta.modeState` / `_meta.modelState` of the answer are the session state
some agents publish before a session exists.
`authMethods` become `AgentInfo.authMethods`. A method of type `terminal`
is the agent's own program run interactively; the spec forbids passing it
to `authenticate`, so its description says what to run (the agent's
program and the method's `args`) and choosing it fails with that hint.
## Authentication
ACP answers `-32000` when the user must sign in, usually at
`session/new`. The SDK's `RpcError` keeps the code, so it is read
structurally (`isAuthRequired`, which also matches the older
`(code -32000)` message form). Once seen, the next `initialize` reports
`auth_required`; `authenticate { methodId }` clears it.
A browser opens only because the user asked for it. `agent/authenticate`
runs only when the user picks a method in settings; while it runs, a URL
elicitation the agent sends opens its page (`api.openUrl`, which the
runtime allows a provider during a sign-in). At any other time the link
goes to the chat (or the app's notices) as a message the user follows.
Some agents need to be told a credential before any session
(`quirks.authMethod`); only a method that opens nothing is sent on its
own (Grok's `xai.api_key` / `cached_token`), never Cursor's browser login.
## Sessions
- `create_session` -> `session/new { cwd, mcpServers[,
additionalDirectories] }`. The response's `modes`, `models` and
`configOptions` are the first option snapshot. Updates the agent sends
before `session/new` returns (modes, options, commands) are buffered and
applied once the session has an id.
- ACP has no way to pass chosen option values to `session/new`, so the
create options are applied right after creation with `session/set_mode`
/ `session/set_model` / `session/set_config_option`.
- `resume_session` -> `session/load` when `loadSession` is advertised.
`session/load` replays the whole session as `session/update`
notifications **while the request is in flight**; those go to
`map.Recorder` instead of the live stream and are kept for
`read_session`. Several agents replay everything and never answer, so a
load also ends when the replay has been quiet for 2 s (never before the
first replayed update), and fails after 90 s. Otherwise
`session/resume` when `sessionCapabilities.resume` is advertised
(re-attach, no replay), and otherwise a fresh session plus a warning
notice.
- A `session/update` with `_meta.isReplay` outside a load is history the
host already has and is dropped.
- Session ids are translated: each session holds the `live` id the agent
knows, `byLive` maps it back, requests go out with the live id and every
event is emitted under the host's id. They differ after the fresh
session fallback and after a restart, which keeps the chat addressable.
- A session belongs to one process. After the agent restarted (a crash, a
hung cancel, a new permission mode), the next call re-establishes it by
load, resume, or a fresh session with a notice.
- `read_session` returns the cached replay, or performs the load itself.
- `list_sessions` -> `session/list { cwd, cursor }` only when
`sessionCapabilities.list` is advertised, paging on `nextCursor` and
dropping sessions whose own `cwd` is another folder.
- `fork_session` -> `session/fork` when advertised; the copy is a session
of its own.
- `close_session` -> `session/close` when advertised; it always releases
that session's terminals and its tools server.
- `additionalDirectories` (a custom entry's `directories`) is sent only
when `sessionCapabilities.additionalDirectories` is advertised.
## Plugin tools
The host gives each session its plugin tools. ACP agents take extra tools
as MCP servers, so the plugin asks the host for its loopback MCP server
(`host/mcp.serve { agentId, sessionId, workspace, tools }`) and adds it to
`mcpServers` as `{ type: "http", name: "convergence", url, headers: [] }`,
beside a custom entry's own servers, on `session/new`, `session/load`,
`session/resume` and `session/fork`. Only when the agent's `initialize`
says `mcpCapabilities.http`; otherwise the chat gets a warning notice that
the tools are not available. A session made before its id is known is
served for the workspace, then narrowed to the session (`host/mcp.serve`
again with the same `url`); `close_session` closes it (`host/mcp.close`).
The agent names the tools its own way (Kilo: `convergence_lucky_number`);
a call may go through the agent's own permission request like any other.
Gemini CLI connects no MCP server in a folder it does not trust (its
folder trust is on by default), so its chats get plugin tools only in
folders the user trusted in Gemini.
`instructions` (plugin rules, the tools guide) have no ACP field. They go
ahead of a session's first prompt as a text block wrapped in
`<convergence-instructions>`, which the `Recorder` strips from a replay.
## Runs
`prompt` sends `session/prompt { sessionId, prompt }` without a timeout
and answers with the run id at once. The response's `stopReason` becomes
exactly one `run_finished` (`cancelled` -> cancelled, `refusal` -> failed,
everything else -> completed); `max_tokens` and `max_turn_requests` also
add a warning notice, because the transcript alone does not say the turn
stopped early. A transport error becomes an error notice plus
`run_finished { failed }`. `finishRun` takes the run id out of the
session, so a run can never finish twice. When the agent process dies,
every open run fails with the exit status and the last lines it wrote.
ACP allows one prompt per session: a second one while one runs is
refused.
Prompt blocks are checked against `promptCapabilities` before they go out
(an image to an agent without `image` fails here with a message the user
can act on); a mentioned file is a `resource_link`, a skill is its
`/command`.
`cancel` sends the `session/cancel` **notification**, answers every
pending permission request of that session with `{ outcome: { outcome:
"cancelled" } }` (the spec requires this), cancels pending questions and
elicitations, and waits up to 15 s for the prompt to answer `cancelled`.
An agent that ignores the stop is restarted (its turn finishes as
cancelled, not failed). It never starts the agent process just to cancel.
## Streaming
`agent_message_chunk` / `agent_thought_chunk` become `text_delta` /
`reasoning_delta` in `append` mode, byte for byte. The item id is the
agent's `messageId` when it sends one, otherwise one generated per
session; any non-chunk update (tool call, plan) closes the open items so
the next chunk starts a new message. `user_message_chunk` is dropped on
the live path (the host already has the user's message) and kept on the
replay path.
`tool_call` / `tool_call_update` map straight through: the ACP `kind` is
structured, so nothing is guessed from shell strings. ACP has no tool
name, so a row's name is its tool call id and its title is the agent's
title; an update's empty title keeps the old one. Diffs keep
`oldText`/`newText` as whole-file snapshots, which is what ACP sends. A
tool result image stays an image. Updates are coalesced per tool call
(`throttle.ts`: small repeated growth is held back, real growth and a
final status go out at once, everything held is released when the run
ends), and text and terminal output in an event are capped to their
newest 8 000 characters (a diff is never cut).
`usage_update` (`used`, `size`, `cost` in USD) is the context meter and
is kept per session. The unstable `PromptResponse.usage` is a total over
the whole session, so it only adds the token breakdown; the context and
window stay those of the last `usage_update`.
`plan` becomes a `plan` event, `available_commands_update` the chat's
slash commands (kept for `list_commands`), `session_info_update` the
title.
## Terminals
`terminal/create` spawns a real child through the broker (`process.any`),
`cwd` and `env` as asked, no input. stdout and stderr are interleaved
into one buffer kept to the newest `outputByteLimit` bytes (1 MiB when the
agent sets none), never splitting a character. `terminal/output`,
`wait_for_exit`, `kill` and `release` operate on it; `wait_for_exit`
answers when the command ends and never blocks the other requests.
A terminal content item only carries a terminal id, so the plugin
remembers which tool call embeds which terminal and re-emits that tool
call's terminal content when the command exits or the terminal is
released. Otherwise the row would show an empty terminal, because the
agent announces the terminal before any output exists.
## Files
`fs/read_text_file` (`line` 1-based, `limit` in lines) and
`fs/write_text_file` resolve the path against the session's workspace and
refuse anything outside it (`..` resolved, Windows paths compared by
segment). The broker checks the same path against the `workspace` grant
with links followed, so a link out of the workspace is refused there. A
session never reaches another open project.
## Elicitation and approvals
`elicitation/create` (and ACP 0.11's `session/elicitation`) with
`mode: "form"` becomes a `question` built from the JSON-schema properties
(`enum`/`enumNames`, `oneOf` with `const`/`title`, arrays -> multi-select,
booleans, numbers, in the schema's order) and is answered with
`{ action: "accept", content }` (numbers as numbers) or
`{ action: "cancel" }`. A form outside any session is declined with a
notice. `mode: "url"` posts a notice with the URL and keeps the request
open until the agent sends `elicitation/complete` (the sign-in flow of
several agents); an unknown mode is declined.
A permission request becomes an `approval` with the agent's options, a
warning when the agent attaches one, and an extra way out (`acp:cancel`)
that answers `cancelled` rather than a choice the agent never offered.
## Config options
ACP keeps modes outside `configOptions`, so `map.options` synthesises one
`mode` selector (left out when the agent publishes a `mode` config option,
or a config option with the same choices). Agents that publish `models`
get a synthesised `model` selector, and a `reasoning_effort` selector when
the models carry reasoning levels in `_meta.reasoningEffort` (a model
change is `session/set_model`; the effort goes in its
`_meta.reasoningEffort`, and only when the user chose one). `category`
maps `model` -> model, `thought_level` -> reasoning, anything else to
itself; booleans are toggles; an option of a type this client does not
know is left out, not the snapshot. No catalogue is ever hardcoded: every
choice comes from the live `session/new`, `session/set_config_option`,
`config_option_update` or the agent's own catalogue method. Reasoning
effort is spec-driven: whatever `thought_level` option or per-model
levels an agent publishes are offered, for every agent.
Every agent also gets the shared `permission_mode` option (Supervised,
Auto-accept edits, Auto where the agent has its own reviewer, Full
access). Agents that take the mode as a process argument restart under a
new mode once no run is open; Antigravity takes it as a session mode.
`list_options(workspace)` returns the last snapshot if there is one,
otherwise creates a throwaway session in that workspace to read the
agent's options, and closes it.
## Quirks
`quirks.ts` holds every documented interoperability bug and vendor
extension; nothing else branches on an agent id, and an agent the table
does not know gets the spec's behaviour.
- **Cursor** (`cursor`): the parameterized model picker capability above;
no client terminal (it runs its own shell); `--auto-review` / `--force`
before `acp` for Auto / Full access; `cursor/ask_question`,
`cursor/create_plan`, `cursor/update_todos`; its model catalogue from
`cursor/list_available_models` when `session/new` has none, and model
changes as a config option. Its browser login is offered in settings,
never sent on its own.
- **Grok** (`grok-build`): `--permission-mode` before `agent` and
`--always-approve` before `stdio`; signs in with `xai.api_key` when
`XAI_API_KEY` is set, else `cached_token`, before any session;
`x.ai/ask_user_question` and `x.ai/exit_plan_mode` (and their `_x.ai/`
spellings); `_x.ai/session/prompt_complete`, an end of turn reported out
of band and matched by the `promptId` sent in the prompt's `_meta`.
- **Antigravity** (`antigravity-acp`): the permission mode as a session
mode; shell calls without a `kind` classified from their input; its
Google sign-in URL read from plain text; `start_subagent` calls as
subagent tasks; permission requests that are questions asked as
questions; its namespaced option warnings.
- **Factory Droid** (`factory-droid`): always `--output-format
acp-daemon`. The plain `acp` format runs every session in one process
and sends each `session/update` to all of them, so chats would see each
other's turns; a launch spelled the old way (a custom entry, an older
registry) is corrected.
- **Oh My Pi** (`omp`): a `KNOWN` agent (`omp acp`), with its own mark.
## Tests
`node --import ./plugins/testing/register.mjs --test plugins/acp/*.test.ts`: the Rust unit tests ported, plus
the transport. The plugin drives the SDK's JSON-RPC process transport
against scripted fake agents (`../sdk/testing.ts`), and the reply the
plugin writes is asserted, not just the event.
Live tests (`crates/host/tests/e2e.rs`, ignored by default):
- `acp_initializes_and_lists_its_options` spends no usage: the host loads
the plugin as JavaScript in the plugin host, the registry is downloaded
and its agents registered (39 on 2026-09-26, Oh My Pi among them, the
native three left out), a `custom.json` in the data folder is read, and
the chosen agent initializes and lists its options.
- `acp_calls_a_plugin_tool` runs one short prompt: Kilo (`kilo acp`) on
its gateway's free `kilo/kilo-auto/free`, which calls the probe plugin's
`lucky_number` through the host's loopback MCP server and answers with
its result. `CONVERGENCE_E2E_ACP_AGENT` / `CONVERGENCE_E2E_ACP_MODEL`
choose another agent and model.
Result on this machine (2026-09-26): both pass. Gemini CLI initializes
through `npx @google/gemini-cli@0.61.0 --acp`, but its free tier for
individuals now refuses ACP clients at `session/new` ("This client is no
longer supported for Gemini Code Assist for individuals").
## Gaps and deliberate omissions
- `session/delete`, `providers/*`, `mcp/*`, the NES family and
`document/did*` are not implemented; none has a place in the agent
protocol yet.
- Compaction updates (`compaction_update`, `compaction_summary_chunk`) and
`plan_update` / `plan_removed` are ignored.
- There is no settings page for custom entries yet. The Rust plugin's
registry and mark caches in `legacy/` are not read; the plugin downloads
them again.Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.9 KB | 6 permissions | Listed |
No comments yet.