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
files.ts3 KB
// The `fs/*` client methods: which paths an agent may read and write, and
// the line slice `fs/read_text_file` asks for.
//
// The bridge reads and writes through the broker, which already refuses
// anything outside the workspaces the user opened (the `workspace` grant),
// symlinks followed. On top of that a session may only reach its own
// workspace: without this check an agent in one project could read or
// write another open project.
/// Splits a path into its segments, `.` and `..` resolved; `null` when
/// `..` climbs above the root.
function segments(path: string) {
const out: string[] = [];
for (const part of path.split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!out.length) return null;
out.pop();
} else {
out.push(part);
}
}
return out;
}
/// `path` resolved against `workspace` (relative paths are inside it),
/// or an `Error` when it leaves the workspace.
export function contain(workspace: unknown, path: unknown) {
const root = typeof workspace === "string" ? workspace : "";
const asked = typeof path === "string" ? path : "";
if (!root) throw new Error(`${asked}: this session has no workspace, so files cannot be read or written`);
if (!asked) throw new Error("a file request needs a path");
// A Windows path (`C:\\x`) is compared with forward slashes, its drive
// the first segment.
const drive = /^[A-Za-z]:[\\/]/.test(root);
const slashed = (value: string) => (drive ? value.replace(/\\/g, "/") : value);
const absolute = drive ? /^[A-Za-z]:\//.test(slashed(asked)) : asked.startsWith("/");
const base = segments(slashed(root));
const joined = segments(absolute ? slashed(asked) : `${slashed(root)}/${slashed(asked)}`);
if (base === null || joined === null) throw new Error(`${asked}: outside the workspace`);
const same = (a: string, b: string | undefined) => (drive ? a.toLowerCase() === b?.toLowerCase() : a === b);
const inside = base.length <= joined.length && base.every((part, index) => same(part, joined[index]));
if (!inside) throw new Error(`${asked}: outside the workspace`);
return drive ? joined.join("/") : `/${joined.join("/")}`;
}
/// The parent folder of an absolute path.
export function parentOf(path: string) {
const at = path.lastIndexOf("/");
return at <= 0 ? "/" : path.slice(0, at);
}
/// `line` is 1-based and `limit` counts lines, as in the ACP schema. With
/// neither the whole text comes back unchanged.
export function sliceLines(text: string, line?: number, limit?: number) {
const hasLine = Number.isInteger(line);
const hasLimit = Number.isInteger(limit);
if (!hasLine && !hasLimit) return text;
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop();
const start = Math.max((hasLine ? (line ?? 1) : 1) - 1, 0);
let picked = lines.slice(start).map((entry) => (entry.endsWith("\r") ? entry.slice(0, -1) : entry));
if (hasLimit) picked = picked.slice(0, Math.max(limit ?? 0, 0));
return picked.join("\n");
}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.