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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.9 KB | 6 permissions | Listed |
No comments yet.