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
quirks.ts19.5 KB
// Everything this plugin knows about one particular ACP agent.
//
// ACP parsing is spec driven; nothing outside this file branches on an
// agent id. What lives here is the list of documented interoperability
// bugs and vendor extensions that the spec has no room for: methods an
// agent invents, flags its process needs for a permission mode, a sign-in
// URL it prints instead of sending, a tool call that is really a
// subagent, a launch flag that makes one process serve every session.
//
// Every hook has a spec-correct default, so an agent the table does not
// know behaves exactly as the protocol says.
import { permissionMode } from "../sdk/agent.ts";
import * as z from "zod";
import type { PlanEntry, QuestionRequest, TaskInfo } from "convergence/protocol";
import type { Message } from "./wire.ts";
/// Registry ids with an entry in this table.
export const CURSOR = "cursor";
export const GROK = "grok-build";
export const ANTIGRAVITY = "antigravity-acp";
export const DROID = "factory-droid";
const KNOWN = new Set([CURSOR, GROK, ANTIGRAVITY, DROID]);
const SIGN_IN = "Open the following link to authenticate the ACP server: ";
/// Extension methods: what an agent invented because ACP has no field for
/// it. `{ kind: "ask_question" | "propose_plan", dialect }`, or
/// `{ kind: "update_todos" }` / `{ kind: "prompt_complete" }` for the two
/// notifications.
export const Dialect: { readonly CURSOR: "cursor"; readonly XAI: "xai" } = { CURSOR: "cursor", XAI: "xai" };
export type Dialect = (typeof Dialect)[keyof typeof Dialect];
const record = z.record(z.string(), z.unknown());
const optionalString = z.string().optional().catch(undefined);
const optionalBoolean = z.boolean().optional().catch(undefined);
const unknownList = z.array(z.unknown()).catch([]).optional();
const callSchema = z.looseObject({
toolCallId: z.unknown().optional(),
title: z.unknown().optional(),
kind: z.unknown().optional(),
_meta: z.unknown().optional(),
});
const permissionSchema = z.looseObject({ toolCall: z.unknown().optional(), _meta: z.unknown().optional() });
const optionSchema = z.looseObject({ _meta: z.unknown().optional() });
const catalogSchema = z.looseObject({
models: unknownList,
});
const questionParamsSchema = z.looseObject({
toolCallId: optionalString,
title: optionalString,
questions: unknownList,
});
const planParamsSchema = z.looseObject({
todos: unknownList,
phases: unknownList,
plan: optionalString,
planContent: optionalString,
});
const answerSchema = z.looseObject({
cancelled: optionalBoolean,
values: z
.union([record, z.array(z.unknown())])
.catch({})
.optional(),
});
export type AcpSessionUpdate = Message;
export type QuestionShape =
{ dialect: typeof Dialect.CURSOR } | { dialect: typeof Dialect.XAI; texts: Map<string, string> };
export type QuestionMapping = { question: QuestionRequest; shape: QuestionShape };
export type AuthMethod = { id: string; interactive: boolean };
export type Extension =
{ kind: "ask_question" | "propose_plan"; dialect: Dialect } | { kind: "update_todos" | "prompt_complete" };
function asRecord(value: unknown): Record<string, unknown> {
const parsed = record.safeParse(value);
return parsed.success ? parsed.data : {};
}
export class Quirks {
private readonly id: string;
private constructor(id: string) {
this.id = id;
}
static forAgent(registryId: string): Quirks {
return new Quirks(KNOWN.has(registryId) ? registryId : "");
}
/// The agent classifies routine actions itself, so the unified `auto`
/// mode means something on it. Offering it on an agent without a
/// reviewer would quietly mean "full access".
reviewer() {
return this.id === CURSOR || this.id === GROK;
}
/// Extra `initialize` client capabilities, merged into the standard set.
clientCapabilities(capabilities: Record<string, unknown>): Record<string, unknown> {
// Without this Cursor bakes every parameter into the model id
// (`gpt-5.4[reasoning=high]`) instead of publishing separate
// `thought_level` and `model_config` options. Zed sends it for the same
// single agent.
if (this.id === CURSOR) capabilities._meta = { parameterizedModelPicker: true };
return capabilities;
}
/// The process arguments for `mode`, built from the registry's own.
spawnArgs(base: readonly string[], mode: string): string[] {
const args = [...base];
switch (this.id) {
// `cursor-agent [flags] acp`.
case CURSOR:
if (mode === permissionMode.AUTO) return insertBefore(args, "acp", ["--auto-review"]);
if (mode === permissionMode.FULL) return insertBefore(args, "acp", ["--force"]);
return args;
// `grok [--permission-mode M] agent [--always-approve] stdio`.
case GROK:
if (mode === permissionMode.SUPERVISED) return insertBefore(args, "agent", ["--permission-mode", "default"]);
if (mode === permissionMode.AUTO_EDITS)
return insertBefore(args, "agent", ["--permission-mode", "acceptEdits"]);
if (mode === permissionMode.AUTO) return insertBefore(args, "agent", ["--permission-mode", "auto"]);
if (mode === permissionMode.FULL) return insertBefore(args, "stdio", ["--always-approve"]);
return args;
// Droid's plain `acp` output format runs every session in one
// process and sends each `session/update` to all of them, so chats
// see each other's turns. `acp-daemon` gives each session a worker
// of its own (what the registry and Zed launch). A launch spelled the
// old way, from a custom entry or an older registry, is corrected.
case DROID:
return daemonFormat(args);
default:
return args;
}
}
/// The session-level mode id that carries the permission mode, for
/// agents that take it over the wire instead of on the command line.
permissionModeId(mode: string): string | null {
if (this.id !== ANTIGRAVITY) return null;
if (mode === permissionMode.FULL) return "yolo";
if (mode === permissionMode.AUTO_EDITS) return "auto_edit";
return "default";
}
/// The `authenticate` method this agent needs before its first session,
/// for agents that refuse every session method until they have been told
/// which credential to use: `{ id, interactive }`. `interactive` opens a
/// browser; Convergence never starts one of those on its own, the user
/// asks for it in settings first. `env` is the environment the agent
/// process sees.
authMethod(env: Readonly<Record<string, string | undefined>> = {}): AuthMethod | null {
if (this.id === CURSOR) return { id: "cursor_login", interactive: true };
if (this.id === GROK) {
const key = typeof env.XAI_API_KEY === "string" ? env.XAI_API_KEY.trim() : "";
return key ? { id: "xai.api_key", interactive: false } : { id: "cached_token", interactive: false };
}
return null;
}
/// An agent that runs its own commands must not be offered `terminal/*`,
/// or it announces terminals it never fills.
terminal() {
return ![CURSOR, GROK, ANTIGRAVITY].includes(this.id);
}
/// A `session/update` payload, normalised before it is read.
sessionUpdate(update: AcpSessionUpdate): AcpSessionUpdate {
if (this.id !== ANTIGRAVITY) return update;
// Antigravity leaves `kind` off the shell calls of its own harness and
// names the command the way that harness does, so without this every
// command it runs renders as an unclassified tool row.
if (typeof update.kind === "string") return update;
const input = update.rawInput;
if (input === undefined) return update;
if (firstString(input, ["CommandLine", "command_line", "commandLine", "command"]) !== null) update.kind = "execute";
return update;
}
/// What a line the agent wrote outside the protocol means: `{ signIn:
/// url }`, or `null` for nothing. Antigravity prints its Google sign-in
/// URL as plain text on both streams instead of asking for a URL
/// elicitation, so the user would otherwise see an agent that hangs at
/// startup.
diagnostic(line: unknown): { signIn: string } | null {
if (this.id !== ANTIGRAVITY || typeof line !== "string") return null;
const trimmed = line.trim();
if (!trimmed.startsWith(SIGN_IN)) return null;
const url = trimmed.slice(SIGN_IN.length).trim();
return url.startsWith("https://accounts.google.com/") ? { signIn: url } : null;
}
/// The extension this method belongs to, if the plugin serves it.
extension(method: string): Extension | null {
if (this.id === CURSOR) {
if (method === "cursor/ask_question") return { kind: "ask_question", dialect: Dialect.CURSOR };
if (method === "cursor/create_plan") return { kind: "propose_plan", dialect: Dialect.CURSOR };
if (method === "cursor/update_todos") return { kind: "update_todos" };
}
if (this.id === GROK) {
// Both spellings are live: xAI renamed the namespace and kept the old
// one working.
if (method === "x.ai/ask_user_question" || method === "_x.ai/ask_user_question")
return { kind: "ask_question", dialect: Dialect.XAI };
if (method === "x.ai/exit_plan_mode" || method === "_x.ai/exit_plan_mode")
return { kind: "propose_plan", dialect: Dialect.XAI };
if (method === "_x.ai/session/prompt_complete") return { kind: "prompt_complete" };
}
return null;
}
/// A method this agent publishes its model catalogue on, for agents that
/// answer `session/new` without one.
modelCatalog(): string | null {
return this.id === CURSOR ? "cursor/list_available_models" : null;
}
/// True when a model change is an ordinary config option on this agent
/// rather than `session/set_model`.
modelIsConfigOption() {
return this.id === CURSOR;
}
/// The `_meta` this agent needs on a prompt to match an out-of-band
/// completion to the turn it ended.
promptMeta(promptId: string): { promptId: string; requestId: string } | null {
return this.id === GROK ? { promptId, requestId: promptId } : null;
}
/// True when `subagent` can recognise this agent's subagents.
subagents() {
return this.id === ANTIGRAVITY;
}
/// A tool call that is really a subagent, as the task it stands for.
subagent(value: unknown): TaskInfo | null {
if (this.id !== ANTIGRAVITY) return null;
const parsed = callSchema.safeParse(value);
if (!parsed.success) return null;
const call = parsed.data;
// The only signals ACP 1.1.1 leaves: an unclassified tool call with one
// of two fixed titles. An MCP call can wear the same title.
const titled = call?.title === "Running start_subagent" || call?.title === "Run start_subagent?";
const unclassified = call?.kind === undefined || call?.kind === null || call?.kind === "other";
const mcp = asRecord(call._meta).is_mcp_tool_call === true;
if (!titled || !unclassified || mcp) return null;
return {
id: String(call.toolCallId),
title: "Subagent batch",
status: "running",
toolCallId: String(call.toolCallId),
};
}
/// True when a permission request is really a question: a list of
/// choices rather than a yes or no about one action.
permissionIsQuestion(value: unknown): boolean {
if (this.id !== ANTIGRAVITY) return false;
const parsed = permissionSchema.safeParse(value);
if (!parsed.success) return false;
const toolCall = asRecord(parsed.data.toolCall);
const id = typeof toolCall.toolCallId === "string" ? toolCall.toolCallId : "";
return id.startsWith("interaction_") || asRecord(parsed.data._meta).isAntigravityUserInputRequest === true;
}
/// The warning an approval option carries, for agents that namespace it.
optionWarning(value: unknown): string | null {
if (this.id !== ANTIGRAVITY) return null;
const parsed = optionSchema.safeParse(value);
if (!parsed.success) return null;
const warning = asRecord(parsed.data._meta)["agy.security.warning"];
if (!warning || typeof warning !== "object") return null;
return firstString(warning, ["message", "title"]);
}
}
/// Inserts `flags` before the first occurrence of `anchor`, or appends them
/// when the registry spells the launch differently than expected.
export function insertBefore(base: readonly string[], anchor: string, flags: readonly string[]): string[] {
const out = [...base];
const found = out.indexOf(anchor);
out.splice(found < 0 ? out.length : found, 0, ...flags);
return out;
}
/// `--output-format acp` (either spelling) as `acp-daemon`.
export function daemonFormat(args: readonly string[]): string[] {
const out = [...args];
for (let index = 0; index < out.length; index += 1) {
if (out[index] === "--output-format" && out[index + 1] === "acp") out[index + 1] = "acp-daemon";
else if (out[index] === "--output-format=acp") out[index] = "--output-format=acp-daemon";
}
return out;
}
function firstString(value: unknown, keys: readonly string[]): string | null {
const parsed = record.safeParse(value);
if (!parsed.success) return null;
for (const key of keys) {
const found = parsed.data[key];
if (typeof found === "string" && found.trim()) return found.trim();
}
return null;
}
// --- model catalogue -------------------------------------------------------------------
/// A model catalogue answered on an extension method (`{ models: [{ value,
/// name }] }`), as the model state `session/new` should have carried.
/// `current` is what the agent is already set to; the catalogue does not
/// say, so the first model stands in rather than an empty picker.
export function catalogState(value: unknown, current = "") {
const parsed = catalogSchema.safeParse(value);
const available = (parsed.success ? (parsed.data.models ?? []) : [])
.map(asRecord)
.map((model) => ({
value: typeof model.value === "string" ? model.value.trim() : "",
name: typeof model.name === "string" ? model.name.trim() : "",
}))
.filter((model) => model.value && model.name)
.map((model) => ({ modelId: model.value, name: model.name }));
const first = available[0];
if (!first) return null;
const currentModelId = available.some((model) => model.modelId === current) ? current : first.modelId;
return { currentModelId, availableModels: available };
}
// --- questions -----------------------------------------------------------------------
/// Reads an `ask_question` payload of either dialect as one question with
/// a field per entry: `{ question, shape }`, or `null` when nothing in it
/// can be answered. `shape` is what `answer` needs.
export function question(dialect: Dialect, id: string, value: unknown): QuestionMapping | null {
const parsed = questionParamsSchema.safeParse(value);
const params = parsed.success ? parsed.data : {};
const entries = (params.questions ?? []).map(asRecord);
if (!entries.length) return null;
const toolCallId = typeof params?.toolCallId === "string" ? params.toolCallId : "";
const texts = new Map<string, string>();
const fields: QuestionRequest["fields"] = entries.map((entry, index) => {
const text =
typeof entry?.prompt === "string" ? entry.prompt : typeof entry?.question === "string" ? entry.question : "";
// xAI keys its answers by the question text, so an entry without an id
// still has to be addressable.
const fieldId = typeof entry?.id === "string" ? entry.id : text ? text : `${toolCallId}-${index}`;
texts.set(fieldId, text);
const multiple = entry?.allowMultiple === true || entry?.multiSelect === true;
const options = (Array.isArray(entry.options) ? entry.options : []).map(asRecord).flatMap((option) => {
if (typeof option.label !== "string") return [];
// Cursor matches on its own option id, xAI on the label; using the
// label as the value when there is no id keeps both answerable.
return [
{
value: typeof option.id === "string" ? option.id : option.label,
label: option.label,
...(typeof option.description === "string" ? { description: option.description } : {}),
},
];
});
return {
id: fieldId,
label: text,
kind: multiple ? "multi_select" : "select",
options,
allowOther: true,
required: false,
};
});
const question: QuestionRequest = {
id,
fields,
...(typeof params.title === "string" ? { message: params.title } : {}),
};
const shape: QuestionShape = dialect === Dialect.XAI ? { dialect, texts } : { dialect };
return { question, shape };
}
/// The user's answer in the shape the agent that asked expects.
export function answer(shape: QuestionShape, value: unknown): Record<string, unknown> {
const parsed = answerSchema.safeParse(value);
const reply = parsed.success ? parsed.data : {};
const values = reply.values ?? {};
if (shape.dialect !== Dialect.XAI) return { answers: values };
if (reply?.cancelled) return { outcome: "cancelled" };
const answers: Record<string, ReadonlyArray<unknown>> = {};
if (!("texts" in shape)) return { answers: values };
for (const [field, value] of Object.entries(values)) {
if (!shape.texts.has(field)) continue;
const text = shape.texts.get(field);
if (text !== undefined) answers[text] = Array.isArray(value) ? value : [value];
}
return { outcome: "accepted", answers };
}
// --- plans -------------------------------------------------------------------------------
/// A proposed plan as plan entries, plus the answer that lets the turn
/// continue: `{ plan, reply }`. Neither agent's plan call is a question:
/// the client takes the plan and the user decides in their own time, so
/// the request is answered at once.
export function proposedPlan(
dialect: Dialect,
value: unknown,
): { plan: { entries: PlanEntry[] }; reply: Record<string, unknown> } {
const parsed = planParamsSchema.safeParse(value);
const params = parsed.success ? parsed.data : {};
const todos = [
...(Array.isArray(params?.todos) ? params.todos : []),
...(Array.isArray(params?.phases) ? params.phases : [])
.map(asRecord)
.flatMap((phase) => (Array.isArray(phase.todos) ? phase.todos : [])),
];
const entries = todos.map(todoEntry).filter((entry): entry is PlanEntry => entry !== null);
// A plan written as prose has no todos; keep the markdown as one entry
// rather than showing an empty plan.
if (!entries.length) {
const prose = [params?.plan, params?.planContent].find((value) => typeof value === "string" && value.trim());
if (prose) entries.push({ content: prose.trim(), status: "pending" });
}
const reply =
dialect === Dialect.CURSOR
? { accepted: true }
: // Telling xAI the plan was abandoned is what ends its plan turn;
// `approved` would make it start building immediately.
{
outcome: "abandoned",
feedback: "The client captured the plan. Stop here and wait for the user's next instruction.",
};
return { plan: { entries }, reply };
}
/// An `update_todos` notification as a plan.
export function todos(value: unknown): { entries: PlanEntry[] } {
const parsed = planParamsSchema.safeParse(value);
const entries = parsed.success ? (parsed.data.todos ?? []) : [];
return { entries: entries.map(todoEntry).filter((entry): entry is PlanEntry => entry !== null) };
}
function todoEntry(value: unknown): PlanEntry | null {
const todo = asRecord(value);
const pick = (value: unknown): string | null => (typeof value === "string" && value.trim() ? value.trim() : null);
const content = pick(todo?.content) ?? pick(todo?.title);
if (content === null) return null;
let status: PlanEntry["status"] = "pending";
if (todo.status === "completed") status = "completed";
else if (todo.status === "in_progress" || todo.status === "inProgress") status = "in_progress";
return { content, status };
}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.