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

quirks.test.ts12.1 KB
// Ported from the Rust plugin's quirks.rs tests.
import { test } from "node:test";
import assert from "node:assert/strict";
import { permissionMode } from "../sdk/agent.ts";
import { Dialect, Quirks, answer, catalogState, daemonFormat, proposedPlan, question, todos } from "./quirks.ts";

test("an unknown agent gets the spec behaviour", () => {
  const plain = Quirks.forAgent("gemini");
  assert.equal(plain.reviewer(), false);
  assert.equal(plain.terminal(), true);
  assert.deepEqual(plain.spawnArgs(["--acp"], permissionMode.FULL), ["--acp"]);
  assert.equal(plain.authMethod({}), null);
  assert.equal(plain.extension("cursor/ask_question"), null);
  assert.equal(plain.diagnostic("Open the following link to authenticate the ACP server: https://x"), null);
  assert.deepEqual(plain.clientCapabilities({}), {});
  assert.equal(plain.modelCatalog(), null);
  assert.equal(plain.promptMeta("p1"), null);
  assert.equal(plain.subagents(), false);
});

test("only Cursor asks for the parameterized model picker", () => {
  assert.deepEqual(Quirks.forAgent("cursor").clientCapabilities({ fs: {} }), {
    fs: {},
    _meta: { parameterizedModelPicker: true },
  });
  assert.deepEqual(Quirks.forAgent("gemini").clientCapabilities({ fs: {} }), { fs: {} });
});

// Both agents take the flag as an argument of a parent subcommand, so
// appending it makes the process refuse to start.
test("permission flags go where the CLI expects them", () => {
  const cursor = Quirks.forAgent("cursor");
  assert.deepEqual(cursor.spawnArgs(["acp"], permissionMode.AUTO), ["--auto-review", "acp"]);
  assert.deepEqual(cursor.spawnArgs(["acp"], permissionMode.FULL), ["--force", "acp"]);
  assert.deepEqual(cursor.spawnArgs(["acp"], permissionMode.SUPERVISED), ["acp"]);

  const grok = Quirks.forAgent("grok-build");
  assert.deepEqual(grok.spawnArgs(["agent", "stdio"], permissionMode.AUTO_EDITS), [
    "--permission-mode",
    "acceptEdits",
    "agent",
    "stdio",
  ]);
  assert.deepEqual(grok.spawnArgs(["agent", "stdio"], permissionMode.FULL), ["agent", "--always-approve", "stdio"]);
  // A launch spelled another way gets the flag at the end.
  assert.deepEqual(cursor.spawnArgs(["serve"], permissionMode.FULL), ["serve", "--force"]);
});

// Droid's plain `acp` format broadcasts every session's updates to all the
// sessions of its process, so chats would see each other's turns.
test("Factory Droid always runs in the acp-daemon output format", () => {
  const droid = Quirks.forAgent("factory-droid");
  const registry = ["exec", "--output-format", "acp-daemon"];
  assert.deepEqual(droid.spawnArgs(registry, permissionMode.FULL), registry, "the registry's own launch is kept");
  assert.deepEqual(droid.spawnArgs(["exec", "--output-format", "acp"], permissionMode.SUPERVISED), registry);
  assert.deepEqual(daemonFormat(["exec", "--output-format=acp"]), ["exec", "--output-format=acp-daemon"]);
  // No other agent is touched.
  assert.deepEqual(Quirks.forAgent("gemini").spawnArgs(["--output-format", "acp"], permissionMode.SUPERVISED), [
    "--output-format",
    "acp",
  ]);
});

test("Grok signs in with the key when there is one", () => {
  const grok = Quirks.forAgent("grok-build");
  assert.deepEqual(grok.authMethod({}), { id: "cached_token", interactive: false });
  assert.deepEqual(grok.authMethod({ XAI_API_KEY: "xai-abc" }), { id: "xai.api_key", interactive: false });
  // An empty value is not a key.
  assert.deepEqual(grok.authMethod({ XAI_API_KEY: "  " }), { id: "cached_token", interactive: false });
  // A browser login is offered, never started on its own.
  assert.deepEqual(Quirks.forAgent("cursor").authMethod({}), { id: "cursor_login", interactive: true });
});

test("Antigravity maps the permission mode onto its own mode id", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  assert.equal(agy.permissionModeId(permissionMode.FULL), "yolo");
  assert.equal(agy.permissionModeId(permissionMode.AUTO_EDITS), "auto_edit");
  assert.equal(agy.permissionModeId(permissionMode.SUPERVISED), "default");
  assert.equal(Quirks.forAgent("cursor").permissionModeId(permissionMode.FULL), null);
});

test("agents that run their own commands are offered no terminal", () => {
  for (const id of ["cursor", "grok-build", "antigravity-acp"]) assert.equal(Quirks.forAgent(id).terminal(), false, id);
  assert.equal(Quirks.forAgent("factory-droid").terminal(), true);
});

test("the sign-in line is recognised", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  const line =
    "Open the following link to authenticate the ACP server: https://accounts.google.com/o/oauth2/v2/auth?x=1";
  assert.deepEqual(agy.diagnostic(line), { signIn: "https://accounts.google.com/o/oauth2/v2/auth?x=1" });
  assert.deepEqual(agy.diagnostic(`  ${line}  `), { signIn: "https://accounts.google.com/o/oauth2/v2/auth?x=1" });
  // Anything that is not a Google sign-in URL is left alone.
  assert.equal(agy.diagnostic("Open the following link to authenticate the ACP server: http://evil.example"), null);
  assert.equal(agy.diagnostic("starting up"), null);
});

test("Antigravity's shell calls are classified from their input", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  assert.equal(agy.sessionUpdate({ sessionUpdate: "tool_call", rawInput: { CommandLine: "ls" } }).kind, "execute");
  assert.equal(
    agy.sessionUpdate({ sessionUpdate: "tool_call", kind: "read", rawInput: { command: "ls" } }).kind,
    "read",
    "a kind the agent sent wins",
  );
  assert.equal(agy.sessionUpdate({ sessionUpdate: "tool_call", rawInput: { path: "a" } }).kind, undefined);
  assert.equal(Quirks.forAgent("gemini").sessionUpdate({ rawInput: { command: "ls" } }).kind, undefined);
});

test("only an unclassified start_subagent call is a task", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  const task = agy.subagent({ toolCallId: "t1", title: "Running start_subagent" });
  assert.deepEqual(task, { id: "t1", title: "Subagent batch", status: "running", toolCallId: "t1" });
  assert.equal(agy.subagent({ toolCallId: "t2", title: "Run command" }), null);
  assert.equal(
    agy.subagent({ toolCallId: "t3", title: "Running start_subagent", kind: "execute" }),
    null,
    "a classified call is whatever the kind says",
  );
  assert.equal(
    agy.subagent({ toolCallId: "t4", title: "Running start_subagent", _meta: { is_mcp_tool_call: true } }),
    null,
    "an MCP call wears the same title",
  );
  assert.equal(Quirks.forAgent("cursor").subagent({ toolCallId: "t5", title: "Running start_subagent" }), null);
  assert.equal(agy.subagents(), true);
});

test("a choice request is a question, not an approval", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  assert.equal(
    agy.permissionIsQuestion({ sessionId: "s", options: [], toolCall: { toolCallId: "interaction_7" } }),
    true,
  );
  assert.equal(agy.permissionIsQuestion({ sessionId: "s", options: [], toolCall: { toolCallId: "call_7" } }), false);
  assert.equal(
    agy.permissionIsQuestion({ sessionId: "s", options: [], _meta: { isAntigravityUserInputRequest: true } }),
    true,
  );
  assert.equal(Quirks.forAgent("gemini").permissionIsQuestion({ toolCall: { toolCallId: "interaction_7" } }), false);
});

test("Antigravity's namespaced option warning is read", () => {
  const agy = Quirks.forAgent("antigravity-acp");
  assert.equal(
    agy.optionWarning({
      optionId: "a",
      _meta: { "agy.security.warning": { title: "Careful", message: " Runs as root " } },
    }),
    "Runs as root",
  );
  assert.equal(agy.optionWarning({ optionId: "a" }), null);
  assert.equal(Quirks.forAgent("cursor").optionWarning({ _meta: { "agy.security.warning": { message: "x" } } }), null);
});

test("extension methods are recognised per agent", () => {
  const cursor = Quirks.forAgent("cursor");
  assert.deepEqual(cursor.extension("cursor/ask_question"), { kind: "ask_question", dialect: Dialect.CURSOR });
  assert.deepEqual(cursor.extension("cursor/create_plan"), { kind: "propose_plan", dialect: Dialect.CURSOR });
  assert.deepEqual(cursor.extension("cursor/update_todos"), { kind: "update_todos" });
  const grok = Quirks.forAgent("grok-build");
  assert.deepEqual(grok.extension("x.ai/ask_user_question"), { kind: "ask_question", dialect: Dialect.XAI });
  assert.deepEqual(grok.extension("_x.ai/ask_user_question"), { kind: "ask_question", dialect: Dialect.XAI });
  assert.deepEqual(grok.extension("_x.ai/exit_plan_mode"), { kind: "propose_plan", dialect: Dialect.XAI });
  assert.deepEqual(grok.extension("_x.ai/session/prompt_complete"), { kind: "prompt_complete" });
  assert.equal(grok.extension("cursor/ask_question"), null);
  assert.deepEqual(grok.promptMeta("p1"), { promptId: "p1", requestId: "p1" });
  assert.equal(cursor.modelCatalog(), "cursor/list_available_models");
  assert.equal(cursor.modelIsConfigOption(), true);
});

test("a model catalogue becomes the model state session/new lacked", () => {
  const catalog = {
    models: [
      { value: " composer-2.5 ", name: "Composer 2.5" },
      { value: "", name: "Blank" },
      { value: "gpt-5", name: "GPT-5" },
    ],
  };
  assert.deepEqual(catalogState(catalog, ""), {
    currentModelId: "composer-2.5",
    availableModels: [
      { modelId: "composer-2.5", name: "Composer 2.5" },
      { modelId: "gpt-5", name: "GPT-5" },
    ],
  });
  const selected = catalogState(catalog, "gpt-5");
  assert.ok(selected);
  assert.equal(selected.currentModelId, "gpt-5");
  assert.equal(catalogState({ models: [] }, ""), null);
});

test("both ask_question dialects become one question", () => {
  const cursor = question(Dialect.CURSOR, "q1", {
    toolCallId: "t1",
    title: "Pick a branch",
    questions: [{ id: "branch", prompt: "Which branch?", options: [{ id: "main", label: "Main" }] }],
  });
  assert.ok(cursor);
  assert.ok(cursor.question.fields[0]?.options?.[0]);
  assert.equal(cursor.question.id, "q1");
  assert.equal(cursor.question.message, "Pick a branch");
  assert.equal(cursor.question.fields[0].id, "branch");
  assert.equal(cursor.question.fields[0].label, "Which branch?");
  assert.equal(cursor.question.fields[0].kind, "select");
  assert.equal(cursor.question.fields[0].options[0].value, "main");
  assert.deepEqual(answer(cursor.shape, { values: { branch: "main" }, cancelled: false }), {
    answers: { branch: "main" },
  });

  const xai = question(Dialect.XAI, "q2", {
    sessionId: "s",
    toolCallId: "t2",
    mode: "default",
    questions: [{ question: "Which branch?", multiSelect: true, options: [{ label: "Main" }, { label: "Dev" }] }],
  });
  assert.ok(xai);
  assert.equal(xai.question.fields[0].id, "Which branch?", "xAI keys its answers by the question text");
  assert.equal(xai.question.fields[0].kind, "multi_select");
  assert.deepEqual(answer(xai.shape, { values: { "Which branch?": ["Main"] }, cancelled: false }), {
    outcome: "accepted",
    answers: { "Which branch?": ["Main"] },
  });
  assert.deepEqual(answer(xai.shape, { values: {}, cancelled: true }), { outcome: "cancelled" });
  assert.equal(question(Dialect.CURSOR, "q3", { questions: [] }), null);
});

test("a proposed plan becomes plan entries and an immediate reply", () => {
  const cursor = proposedPlan(Dialect.CURSOR, {
    toolCallId: "t1",
    plan: "# Plan",
    todos: [{ content: "Read the code", status: "in_progress" }],
    phases: [{ name: "Later", todos: [{ title: "Ship it" }] }],
  });
  assert.equal(cursor.plan.entries.length, 2);
  assert.equal(cursor.plan.entries[0].status, "in_progress");
  assert.equal(cursor.plan.entries[1].content, "Ship it");
  assert.deepEqual(cursor.reply, { accepted: true });
  // Prose-only plans keep their markdown rather than showing nothing.
  const xai = proposedPlan(Dialect.XAI, { planContent: "  # Steps\n1. Do it  " });
  assert.equal(xai.plan.entries[0].content, "# Steps\n1. Do it");
  assert.equal(xai.reply.outcome, "abandoned");
});

test("todos replace the plan", () => {
  const replaced = todos({
    toolCallId: "t1",
    merge: true,
    todos: [{ content: " ", title: "Fallback" }, { content: "Done", status: "completed" }, { title: "  " }],
  });
  assert.equal(replaced.entries.length, 2, "an entry with no text at all is dropped");
  assert.equal(replaced.entries[0].content, "Fallback");
  assert.equal(replaced.entries[1].status, "completed");
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.