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.
  • Run any command process.anyDangerousStarts any program or shell command. This is as strong as your own account.Start the ACP agents, which the registry launches by path or through package runners such as npx and uvx, and run the terminal commands an agent asks for
  • Provide agents agents.provideMediumAdds agents to the app.Provide the agents of the ACP registry, and serve plugin tools to them through the host's loopback MCP server
  • Network access netMediumConnects to the listed hosts.Download the ACP registry and the agents' marks once a dayHosts: cdn.agentclientprotocol.com
  • Read files fs.readMediumReads files in the listed places.Read the files an agent asks for (ACP fs/read_text_file), only inside the chat's workspace, and your own agents from custom.json in this plugin's data folderPlaces: the open workspaceits own data folder
  • Write files fs.writeMediumCreates, changes and deletes files in the listed places.Write the files an agent asks to write (ACP fs/write_text_file), only inside the chat's workspace, and move the custom agents of the old ACP plugin into custom.json oncePlaces: the open workspaceits own data folder
  • Environment variables envMediumReads the listed environment variables.Sign Grok in with your xAI API key when you set one, and tell which Windows build of an agent to runVariables: XAI_API_KEYPROCESSOR_ARCHITECTURE

Files

plugin.ts4.6 KB
// ACP registry lifecycle: register cached, known and custom agents after
// loading their marks, and refresh the daily registry on an hourly timer.
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
import * as Exit from "effect/Exit";
import { Agents } from "convergence/effect";
import type { PluginServices } from "convergence/effect";
import type { RpcTransport } from "../sdk/effect.ts";
import { AcpAgent } from "./agent.ts";
import {
  MAX_CACHE_AGE,
  adoptLegacy,
  assemble,
  fetchIcons,
  loadCustom,
  loadIcons,
  loadRegistry,
  refreshRegistry,
} from "./registry.ts";
import type { Entry, Custom, Icons } from "./registry.ts";
import type { AcpContext } from "./types.ts";
const REFRESH_CHECK = 60 * 60 * 1000;
export class AcpPlugin {
  agents = new Map<string, AcpAgent>();
  registrations = new Map<string, Scope.Closeable>();
  icons: Icons = {};
  custom: Custom[] = [];
  fetchedAt = 0;
  refreshing: Fiber.Fiber<void, unknown> | null = null;
  timer: Fiber.Fiber<never, unknown> | null = null;
  scope: Scope.Scope;
  transport: RpcTransport["Service"];
  context: AcpContext;
  constructor(scope: Scope.Scope, transport: RpcTransport["Service"], context: AcpContext) {
    this.scope = scope;
    this.transport = transport;
    this.context = context;
    this.context.icons = this.icons;
  }
  start = Effect.fn("Acp.start")(function* (this: AcpPlugin, now = Date.now()) {
    const [loaded, icons, custom] = yield* Effect.all(
      [loadRegistry(now), loadIcons(), adoptLegacy().pipe(Effect.andThen(loadCustom()))],
      { concurrency: "unbounded" },
    );
    Object.assign(this.icons, icons);
    this.custom = custom;
    this.fetchedAt = loaded.stale ? 0 : now;
    const entries = assemble(loaded.registry, custom);
    yield* fetchIcons(entries, this.icons);
    yield* this.register(entries);
    if (loaded.stale) yield* this.beginRefresh();
    this.timer = yield* Effect.forever(
      Effect.sleep(REFRESH_CHECK).pipe(
        Effect.andThen(
          Effect.suspend(() => (Date.now() - this.fetchedAt >= MAX_CACHE_AGE ? this.beginRefresh() : Effect.void)),
        ),
      ),
    ).pipe(Effect.forkIn(this.scope));
  });
  register = Effect.fn("Acp.register")(function* (this: AcpPlugin, entries: Entry[]) {
    const listed = new Set(entries.map((entry) => entry.id));
    for (const [id, agent] of [...this.agents]) {
      if (listed.has(id) || agent.entry.source.kind !== "registry") continue;
      const registration = this.registrations.get(id);
      if (registration) yield* Scope.close(registration, Exit.void);
      this.registrations.delete(id);
      this.agents.delete(id);
    }
    const agents = yield* Agents;
    for (const entry of entries) {
      const known = this.agents.get(entry.id);
      if (known) {
        known.setEntry(entry);
        continue;
      }
      const scope = yield* Scope.fork(this.scope, "sequential");
      yield* Effect.gen({ self: this }, function* () {
        const jobs = yield* Effect.acquireRelease(
          Queue.make<Effect.Effect<unknown, unknown, PluginServices>>(),
          Queue.shutdown,
        );
        const agent = new AcpAgent({ entry, context: this.context, scope, transport: this.transport, jobs });
        const handle = yield* agents.register(agent.definition());
        agent.emit = handle.emit;
        this.agents.set(entry.id, agent);
        this.registrations.set(entry.id, scope);
        yield* agent.background();
      }).pipe(Scope.provide(scope));
    }
  });
  beginRefresh = Effect.fn("Acp.beginRefresh")(function* (this: AcpPlugin, now = Date.now()) {
    if (this.refreshing) return;
    this.refreshing = yield* this.refreshEntries(now).pipe(
      Effect.ensuring(
        Effect.sync(() => {
          this.refreshing = null;
        }),
      ),
      Effect.forkIn(this.scope),
    );
  });
  refresh = Effect.fn("Acp.refresh")(function* (this: AcpPlugin, now = Date.now()) {
    yield* this.beginRefresh(now);
    const refreshing = this.refreshing;
    if (refreshing) yield* Fiber.join(refreshing);
  });
  refreshEntries = Effect.fn("Acp.refreshEntries")(function* (this: AcpPlugin, now = Date.now()) {
    const registry = yield* refreshRegistry(now);
    if (!registry) return;
    this.fetchedAt = now;
    this.custom = yield* loadCustom();
    const entries = assemble(registry, this.custom);
    yield* fetchIcons(entries, this.icons, { force: true });
    yield* this.register(entries);
  });
  stop = Effect.fn("Acp.stop")(function* (this: AcpPlugin) {
    if (this.timer) yield* Fiber.interrupt(this.timer);
    this.timer = null;
  });
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.9 KB6 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.