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

registry.ts20.8 KB
// ACP registry, launch resolution, marks and custom entries. Registry and
// icon caches preserve offline startup; custom.json and storage override
// known and published agents. See NOTES.md for the launch contract.
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Data from "effect/Data";
import * as z from "zod";
import type { HostError } from "convergence/effect";
import { Host, Net, Fs, Env, Plugin } from "convergence/effect";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import { OMP_ICON } from "./icons.ts";

export class RegistryFailed extends Data.TaggedError("RegistryFailed")<{ readonly message: string }> {}
const object = z.record(z.string(), z.unknown());
const record = (value: unknown) => object.safeParse(value).data ?? {};
export interface Package {
  package: string;
  args: string[];
  env: Record<string, string>;
}
export interface Binary {
  archive: string;
  cmd: string;
  args: string[];
  env: Record<string, string>;
}
export interface Distribution {
  binary: Record<string, Binary>;
  npx: Package | null;
  uvx: Package | null;
}
export interface Registry {
  agents: {
    id: string;
    name: string;
    description: string;
    version: string | null;
    distribution: Distribution;
    icon: string | null;
  }[];
}
export interface Custom {
  id: string;
  name: string | null;
  command: string;
  args: string[];
  env: Record<string, string>;
  directories: string[];
  mcpServers: unknown[];
}
export interface Entry {
  id: string;
  name: string;
  description: string;
  version: string | null;
  source:
    | { kind: "registry"; distribution: Distribution }
    | { kind: "custom"; command: string; args: string[]; env: Record<string, string>; install: string | null };
  directories: string[];
  mcpServers: unknown[];
  icon: string | null;
  iconUrl: string | null;
}
export interface Launch {
  program: string;
  args: string[];
  env: Record<string, string>;
}
export type Icons = Record<string, unknown>;

export const REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";

/// Registry ids that Convergence already serves with a native provider.
export const BUILT_IN = ["opencode", "claude-acp", "codex-acp"];

/// ACP agents worth serving that the registry does not list: the command
/// that starts the server, and how to get it when it is not installed.
export const KNOWN = [
  {
    id: "omp",
    name: "Oh My Pi",
    description: "Oh My Pi, over its ACP server (`omp acp`).",
    command: "omp",
    args: ["acp"],
    install: "brew install omp",
    icon: OMP_ICON,
  },
];

/// A cache older than this is refreshed.
export const MAX_CACHE_AGE = 24 * 60 * 60 * 1000;
/// Only the very first start waits this long for the registry, when there
/// is no cache to fall back on.
export const FETCH_TIMEOUT = 10_000;
/// A mark is a small drawing. Anything larger is not one.
export const MAX_ICON_BYTES = 64 * 1024;
/// The marks are fetched together, so this bounds the whole set. An agent
/// the host initialized before its mark arrived keeps the generic logo
/// until it is asked again, so the first start waits here instead.
export const ICON_TIMEOUT = 5_000;

const str = (value: unknown) => (typeof value === "string" ? value : null);
const strings = (value: unknown) => (Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);

function env(value: unknown): Record<string, string> {
  const out: Record<string, string> = {};
  if (!value || typeof value !== "object" || Array.isArray(value)) return out;
  for (const [name, text] of Object.entries(value)) if (typeof text === "string") out[name] = text;
  return out;
}

/// The registry document, or `null` when `value` is not one.
export function parseRegistry(value: unknown): Registry | null {
  let json = value;
  if (typeof value === "string") {
    try {
      json = JSON.parse(value);
    } catch {
      return null;
    }
  }
  const source = record(json);
  if (!Array.isArray(source.agents)) return null;
  const agents = source.agents
    .map(record)
    .filter((agent) => typeof agent?.id === "string" && agent.id && typeof agent.name === "string")
    .map((agent) => ({
      id: String(agent.id),
      name: String(agent.name),
      description: str(agent.description) ?? "",
      version: str(agent.version),
      distribution: distribution(agent.distribution),
      icon: str(agent.icon),
    }));
  return { agents };
}

function distribution(value: unknown): Distribution {
  const source = record(value);
  const binary: Record<string, Binary> = {};
  for (const [platform, raw] of Object.entries(record(source.binary))) {
    const entry = record(raw);
    if (typeof entry?.cmd !== "string") continue;
    binary[platform] = {
      archive: str(entry.archive) ?? "",
      cmd: entry.cmd,
      args: strings(entry.args),
      env: env(entry.env),
    };
  }
  const pkg = (value: unknown): Package | null => {
    const entry = record(value);
    return typeof entry.package === "string"
      ? { package: entry.package, args: strings(entry.args), env: env(entry.env) }
      : null;
  };
  return { binary, npx: pkg(source.npx), uvx: pkg(source.uvx) };
}

/// The user's own agents (storage key `custom`); malformed entries are
/// reported and left out.
export function parseCustom(value: unknown): Custom[] {
  if (value === null || value === undefined) return [];
  if (!Array.isArray(value)) {
    console.warn("acp: the stored custom agents are not a list; none are served");
    return [];
  }
  const out = [];
  for (const raw of value) {
    const entry = record(raw);
    if (
      typeof entry?.id !== "string" ||
      !entry.id.trim() ||
      typeof entry.command !== "string" ||
      !entry.command.trim()
    ) {
      console.warn(`acp: ignoring a custom agent without an id and a command: ${JSON.stringify(raw)}`);
      continue;
    }
    out.push({
      id: entry.id.trim(),
      name: str(entry.name),
      command: entry.command.trim(),
      args: strings(entry.args),
      env: env(entry.env),
      directories: strings(entry.directories),
      // MCP servers to hand the agent at `session/new`, in the ACP
      // `McpServer` shape, passed through as written.
      mcpServers: Array.isArray(entry.mcpServers)
        ? entry.mcpServers.filter((server) => server && typeof server === "object")
        : [],
    });
  }
  return out;
}

/// The Convergence agent id of a registry id.
export function agentIdOf(id: string) {
  return `acp:${id}`;
}

/// The registry's agents minus the native ones, plus the known extras and
/// the user's own entries. Later sources replace earlier ones by id; the
/// list is sorted by name.
export function assemble(registry: Registry | null, custom: Custom[] = []): Entry[] {
  let entries: Entry[] = (registry?.agents ?? [])
    .filter((agent) => !BUILT_IN.includes(agent.id))
    .map((agent) => ({
      id: agent.id,
      name: agent.name,
      description: agent.description,
      version: agent.version,
      source: { kind: "registry", distribution: agent.distribution },
      directories: [],
      mcpServers: [],
      icon: null,
      iconUrl: agent.icon,
    }));
  const known: Entry[] = KNOWN.map((agent) => ({
    id: agent.id,
    name: agent.name,
    description: agent.description,
    version: null,
    source: { kind: "custom", command: agent.command, args: [...agent.args], env: {}, install: agent.install },
    directories: [],
    mcpServers: [],
    icon: agent.icon,
    iconUrl: null,
  }));
  const mine: Entry[] = custom.map((agent) => ({
    id: agent.id,
    name: agent.name ?? agent.id,
    description: "",
    version: null,
    source: { kind: "custom", command: agent.command, args: agent.args, env: agent.env, install: null },
    directories: agent.directories,
    mcpServers: agent.mcpServers,
    icon: null,
    iconUrl: null,
  }));
  for (const entry of [...known, ...mine]) {
    entries = entries.filter((existing) => existing.id !== entry.id);
    entries.push(entry);
  }
  return entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
}

// --- launching -------------------------------------------------------------------------

/// The registry's platform key for `uname -sm` output.
export function platformFromUname(text: unknown) {
  const [system = "", machine = ""] = String(text ?? "")
    .trim()
    .split(/\s+/);
  const arm = /^(arm64|aarch64)/i.test(machine);
  if (/^darwin/i.test(system)) return arm ? "darwin-aarch64" : "darwin-x86_64";
  if (/mingw|msys|cygwin|windows/i.test(system)) return arm ? "windows-aarch64" : "windows-x86_64";
  return arm ? "linux-aarch64" : "linux-x86_64";
}

/// The registry's platform key for the machine the app runs on. The
/// runtime does not say, so `uname` does; Windows has none.
export const detectPlatform = Effect.fn("Registry.detectPlatform")(function* () {
  const result = yield* runProcess("uname", ["-sm"], { timeout: 5000 }).pipe(
    // No uname is the Windows detection path.
    Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview", "TransportFailed"], () =>
      Effect.succeed(null),
    ),
  );
  if (result?.code === 0 && result.stdout.trim()) return platformFromUname(result.stdout);
  const env = yield* Env;
  const arch = yield* env
    .get("PROCESSOR_ARCHITECTURE")
    .pipe(Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], () => Effect.succeed(null)));
  return /arm64/i.test(arch ?? "") ? "windows-aarch64" : "windows-x86_64";
});

/// `./dist-package/cursor-agent` -> `cursor-agent`, `./bin\devin.exe` -> `devin`.
export function binaryName(cmd: string) {
  const tail = String(cmd).split(/[/\\]/).pop() || String(cmd);
  return tail.endsWith(".exe") ? tail.slice(0, -4) : tail;
}

/// `@google/gemini-cli@0.59.0` -> `gemini-cli`, `droid@0.218.1` -> `droid`.
export function packageBinary(pkg: string) {
  let name = String(pkg);
  if (name.startsWith("@")) {
    const slash = name.indexOf("/");
    name = slash < 0 ? name.slice(1) : name.slice(slash + 1);
  }
  const at = name.indexOf("@");
  return at < 0 ? name : name.slice(0, at);
}

/// How to start `entry`'s process: `{ program, args, env }`, the program
/// an absolute path the login PATH found. Throws an `Error` whose message
/// says what the user has to install. `which(program)` answers the path
/// or `null`; `platform` is the registry's platform key.
export const resolveLaunch = Effect.fn("Registry.resolveLaunch")(function* <R>(
  entry: Entry,
  { which, platform }: { which: (program: string) => Effect.Effect<string | null, unknown, R>; platform: string },
): Effect.fn.Return<Launch, unknown, R> {
  const source = entry.source;
  if (source.kind === "custom") {
    const program = yield* which(source.command);
    if (program) return { program, args: [...source.args], env: { ...source.env } };
    if (source.install)
      return yield* new RegistryFailed({
        message: `\`${source.command}\` was not found on PATH. Install it with \`${source.install}\`.`,
      });
    return yield* new RegistryFailed({
      message: `\`${source.command}\` was not found on PATH (from your custom ACP agents)`,
    });
  }
  const dist = source.distribution;
  const hints = [];
  const binary = dist.binary[platform];
  if (binary) {
    const command = binaryName(binary.cmd);
    const program = yield* which(command);
    if (program) return { program, args: [...binary.args], env: { ...binary.env } };
    hints.push(
      binary.archive ? `install \`${command}\` (see ${binary.archive})` : `install \`${command}\` and put it on PATH`,
    );
  }
  for (const [runner, pkg] of [
    ["npx", dist.npx],
    ["uvx", dist.uvx],
  ] as const) {
    if (!pkg) continue;
    // A globally installed CLI starts faster than the package runner and
    // is the one the user already signed in with.
    const command = packageBinary(pkg.package);
    const own = yield* which(command);
    if (own) return { program: own, args: [...pkg.args], env: { ...pkg.env } };
    const launcher = yield* which(runner);
    if (launcher) {
      const args = runner === "npx" ? ["-y", pkg.package, ...pkg.args] : [pkg.package, ...pkg.args];
      return { program: launcher, args, env: { ...pkg.env } };
    }
    hints.push(`install \`${command}\`, or install ${runner} to run ${pkg.package}`);
  }
  if (!hints.length) hints.push(`${entry.name} publishes no build for ${platform}`);
  return yield* new RegistryFailed({ message: `${entry.name} is not installed: ${hints.join("; ")}` });
});

// --- marks ---------------------------------------------------------------------------------

/// What the CDN returned has to be a drawing, not an error page.
export function isSvg(text: unknown): text is string {
  if (typeof text !== "string") return false;
  const head = text.trimStart();
  return (
    (head.startsWith("<svg") || head.startsWith("<?xml")) && text.includes("<svg") && text.length <= MAX_ICON_BYTES
  );
}

/// The mark to draw beside an entry's name, as inline SVG: the one this
/// plugin carries, else the registry's from the icon cache.
export function markOf(entry: Pick<Entry, "icon" | "iconUrl" | "id">, icons: Icons) {
  if (entry.icon) return entry.icon;
  if (!entry.iconUrl) return null;
  const svg = icons?.[entry.id];
  return isSvg(svg) ? svg : null;
}

/// What an agent is listed as before it is started: its mark and the
/// registry's description, when there are any.
export function listing(entry: Pick<Entry, "icon" | "iconUrl" | "id" | "description">, icons: Icons) {
  const icon = markOf(entry, icons);
  return {
    ...(icon !== null ? { icon } : {}),
    ...(entry.description ? { description: entry.description } : {}),
  };
}

// --- the cache ---------------------------------------------------------------------------

const recover =
  <A>(key: string, fallback: A) =>
  <B, R>(effect: Effect.Effect<B, HostError, R>) =>
    effect.pipe(
      Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
        Effect.sync(() => {
          console.warn(`acp: ${key} failed: ${error.message}`);
          return fallback;
        }),
      ),
    );
const stored = Effect.fn("Registry.stored")(function* (key: string) {
  const host = yield* Host;
  return (
    (yield* host.call("host/storage.get", { key }).pipe(recover(`reading the stored ${key}`, { value: null }))).value ??
    null
  );
});
const store = Effect.fn("Registry.store")(function* (key: string, value: unknown) {
  const host = yield* Host;
  yield* host.call("host/storage.set", { key, value }).pipe(recover(`storing the ${key}`, undefined));
});
export const fetchRegistry = Effect.fn("Registry.fetchRegistry")(function* () {
  const net = yield* Net;
  const read = Effect.gen(function* () {
    const response = yield* net.fetch(REGISTRY_URL, { headers: { accept: "application/json" } });
    if (!response.ok) return yield* new RegistryFailed({ message: `the ACP registry answered ${response.status}` });
    return yield* Effect.tryPromise({
      try: () => response.text(),
      catch: (cause) => new RegistryFailed({ message: errorMessage(cause) }),
    });
  });
  const text = yield* read.pipe(
    Effect.timeoutOrElse({
      duration: FETCH_TIMEOUT,
      orElse: () =>
        Effect.fail(new RegistryFailed({ message: `the ACP registry did not answer within ${FETCH_TIMEOUT / 1000}s` })),
    }),
  );
  const registry = parseRegistry(text);
  if (!registry)
    return yield* new RegistryFailed({ message: "the ACP registry returned something that is not a registry" });
  return registry;
});
export const loadRegistry = Effect.fn("Registry.loadRegistry")(function* (now = Date.now()) {
  const cached = record(yield* stored("registry"));
  const registry = parseRegistry(cached.registry);
  if (registry) {
    const age = now - (typeof cached.fetchedAt === "number" ? cached.fetchedAt : 0);
    return { registry, stale: !(age >= 0 && age < MAX_CACHE_AGE) };
  }
  return yield* fetchRegistry().pipe(
    Effect.tap((fresh) => store("registry", { fetchedAt: now, registry: fresh })),
    Effect.map((registry) => ({ registry, stale: false })),
    // An offline first launch still serves known and custom agents.
    Effect.catchTag(["RegistryFailed", "HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
      Effect.sync(() => {
        console.warn(`acp: could not fetch the ACP registry and no cache exists: ${error.message}`);
        return { registry: { agents: [] }, stale: false };
      }),
    ),
  );
});
export const refreshRegistry = Effect.fn("Registry.refreshRegistry")(function* (now = Date.now()) {
  return yield* fetchRegistry().pipe(
    Effect.tap((fresh) => store("registry", { fetchedAt: now, registry: fresh })),
    Effect.catchTag(["RegistryFailed", "HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
      Effect.sync(() => {
        console.warn(`acp: the background registry refresh failed: ${error.message}`);
        return null;
      }),
    ),
  );
});
export const loadIcons = Effect.fn("Registry.loadIcons")(function* () {
  return record(yield* stored("icons"));
});
export const fetchIcons = Effect.fn("Registry.fetchIcons")(function* (
  entries: Pick<Entry, "id" | "iconUrl">[],
  icons: Icons,
  { force = false, timeout = ICON_TIMEOUT } = {},
) {
  const wanted = entries.filter((entry) => entry.iconUrl && (force || !isSvg(icons[entry.id])));
  if (!wanted.length) return icons;
  const net = yield* Net;
  let landed = 0;
  const fetch = Effect.fn("Registry.fetchIcon")(function* (entry: Pick<Entry, "id" | "iconUrl">) {
    if (!entry.iconUrl) return;
    const response = yield* net.fetch(entry.iconUrl);
    if (!response.ok) return;
    const svg = yield* Effect.tryPromise({
      try: () => response.text(),
      catch: (cause) => new RegistryFailed({ message: errorMessage(cause) }),
    });
    if (!isSvg(svg)) return;
    icons[entry.id] = svg;
    landed += 1;
  });
  // A missing mark leaves the generic logo; the complete set has one bound.
  const fetching = yield* Effect.all(
    wanted.map((entry) =>
      fetch(entry).pipe(
        Effect.catchTag(["RegistryFailed", "HostCallFailed", "PermissionNotGranted", "NeedsReview"], () => Effect.void),
      ),
    ),
    { concurrency: "unbounded" },
  ).pipe(Effect.forkScoped);
  yield* Fiber.join(fetching).pipe(Effect.raceFirst(Effect.sleep(timeout)));
  if (landed) yield* store("icons", { ...icons });
  return icons;
});
export const CUSTOM_FILE = "custom.json";
const dataFolder = Effect.fn("Registry.dataFolder")(function* () {
  const plugin = yield* Plugin;
  const folder = plugin.api.paths?.data;
  return typeof folder === "string" && folder ? folder.replace(/[/\\]+$/, "") : null;
});
export const loadCustom = Effect.fn("Registry.loadCustom")(function* () {
  const out: Custom[] = [];
  for (const entry of [...parseCustom(yield* customFile()), ...parseCustom(yield* stored("custom"))]) {
    const at = out.findIndex((known) => known.id === entry.id);
    if (at >= 0) out.splice(at, 1);
    out.push(entry);
  }
  return out;
});
export const adoptLegacy = Effect.fn("Registry.adoptLegacy")(function* () {
  const folder = yield* dataFolder();
  if (!folder) return false;
  const host = yield* Host;
  const fs = yield* Fs;
  const adopt = Effect.gen(function* () {
    if ((yield* host.call("host/storage.get", { key: "legacy" })).value) return false;
    // Only missing files are ignored; refusals leave the migration mark unset.
    const read = (path: string) => fs.read(path).pipe(Effect.catchTag("HostCallFailed", () => Effect.succeed(null)));
    const text = yield* read(`${folder}/legacy/${CUSTOM_FILE}`);
    let written = false;
    if (text !== null) {
      const mine = yield* read(`${folder}/${CUSTOM_FILE}`);
      if (mine === null) {
        yield* fs.write(`${folder}/${CUSTOM_FILE}`, text);
        written = true;
        console.info("acp: took the Rust plugin's custom agents into custom.json");
      } else
        console.info(
          `acp: custom.json already exists; the Rust plugin's custom agents stay in ${folder}/legacy/${CUSTOM_FILE}`,
        );
    }
    yield* host.call("host/storage.set", { key: "legacy", value: { at: Date.now() } });
    return written;
  });
  return yield* adopt.pipe(
    Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
      Effect.sync(() => {
        console.warn(`acp: the Rust plugin's custom agents could not be taken over: ${error.message}`);
        return false;
      }),
    ),
  );
});
const customFile = Effect.fn("Registry.customFile")(function* () {
  const folder = yield* dataFolder();
  if (!folder) return null;
  const fs = yield* Fs;
  const path = `${folder}/${CUSTOM_FILE}`;
  const text = yield* fs
    .read(path)
    .pipe(Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], () => Effect.succeed(null)));
  if (text === null) return null;
  try {
    const value: unknown = JSON.parse(text);
    return value;
  } catch (error) {
    console.warn(`acp: ${path} is not JSON, so no custom agents are served from it: ${errorMessage(error)}`);
    return null;
  }
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.