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

map.ts36.6 KB
// Pure translation between ACP wire values and Convergence protocol
// shapes (`plugins/docs/agent-protocol.md`). Field names follow the ACP
// schema (`schema.json` of agentclientprotocol/typescript-sdk) exactly;
// every reader is lenient, so an agent that sends a newer or partial shape
// loses the field, never the whole update.

import * as z from "zod";
import type {
  ConfigChoice,
  ConfigOption,
  ContentBlock,
  Location,
  Plan,
  QuestionField,
  QuestionRequest,
  RunOutcome,
  SlashCommand,
  ToolCall,
  ToolCallUpdate,
  ToolContent,
  ToolKind,
  ToolStatus,
  TranscriptItem,
  Usage,
} from "convergence/protocol";
import { compact, newId } from "../sdk/agent.ts";
import type { Message } from "./wire.ts";

const unknownRecord = z.record(z.string(), z.unknown());
type UnknownRecord = Record<string, unknown>;
export type AcpToolCall = Message;
export interface TerminalView {
  command: string;
  cwd: string | null;
  output: string;
  exitCode: number | null;
}
type MutableUserItem = Extract<TranscriptItem, { role: "user" }>;
type MutableTextItem = Extract<TranscriptItem, { role: "assistant" | "reasoning" }>;

/// Id of the option that carries `session/set_mode`. ACP keeps modes
/// outside `configOptions`, so the plugin synthesises one selector for them.
export const MODE_OPTION = "mode";
/// Id of the option that carries `session/set_model`, synthesised the same
/// way for the agents that publish `models` rather than a config option.
export const MODEL_OPTION = "model";
/// Id of the per-model reasoning selector that goes with `MODEL_OPTION`.
export const EFFORT_OPTION = "reasoning_effort";

/// Option id the plugin answers with `cancelled` instead of a choice. ACP
/// lets a client cancel a permission request, but an agent never offers
/// that as an option, so without one the card has no way out that does not
/// also tell the agent "no".
export const CANCEL_OPTION = "acp:cancel";

/// Wraps the plugin rules and tool guide sent ahead of a session's first
/// prompt, so a replay of the conversation can leave them out again.
export const INSTRUCTIONS_OPEN = "<convergence-instructions>";
export const INSTRUCTIONS_CLOSE = "</convergence-instructions>";

const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
const text = (value: unknown): string | null => (typeof value === "string" && value.trim() ? value : null);
const object = (value: unknown): UnknownRecord => {
  const parsed = unknownRecord.safeParse(value);
  return parsed.success ? parsed.data : {};
};
const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);

// --- prompts -------------------------------------------------------------------------

/// Largest inline blob a prompt may carry, decoded. Past this the agent
/// either refuses the prompt or spends the whole context window on it.
export const MAX_BLOB = 8 * 1024 * 1024;

export type AcpPromptBlock =
  | { type: "text"; text: string }
  | { type: "image" | "audio"; mimeType: string; data: string }
  | { type: "resource_link"; uri: string; name: string }
  | { type: "resource"; resource: { uri: string; text: string; mimeType: string } };

/// The instructions block that opens a session's first prompt.
export function instructionsBlock(instructions: string): { type: "text"; text: string } {
  return { type: "text", text: `${INSTRUCTIONS_OPEN}\n${instructions}\n${INSTRUCTIONS_CLOSE}\n\n` };
}

/// Text with every instructions block taken out.
export function stripInstructions(value: string): string;
export function stripInstructions(value: unknown): unknown;
export function stripInstructions(value: unknown): unknown {
  if (typeof value !== "string" || !value.includes(INSTRUCTIONS_OPEN)) return value;
  let out = value;
  for (;;) {
    const start = out.indexOf(INSTRUCTIONS_OPEN);
    if (start < 0) return out;
    const end = out.indexOf(INSTRUCTIONS_CLOSE, start);
    if (end < 0) return out.slice(0, start);
    let after = end + INSTRUCTIONS_CLOSE.length;
    while (out[after] === "\n") after += 1;
    out = out.slice(0, start) + out.slice(after);
  }
}

/// The prompt's blocks as ACP `ContentBlock`s. An agent declares which
/// block types it accepts, and sending one it did not declare fails the
/// whole prompt with an error the user cannot act on, so the blocks are
/// checked here and the refusal names what to do instead. Throws an
/// `Error` with that message.
export function promptBlocks(blocks: readonly unknown[], capabilities: unknown, agent: string): AcpPromptBlock[] {
  const caps = object(capabilities);
  return blocks.map((value) => {
    const block = object(value);
    switch (block.type) {
      case "text":
        return { type: "text", text: String(block.text ?? "") };
      case "image":
        if (!caps.image) throw new Error(`${agent} cannot read images. Describe the image or paste its text instead.`);
        blob("image", block.mimeType, block.data);
        return { type: "image", mimeType: String(block.mimeType ?? ""), data: String(block.data ?? "") };
      case "file_ref":
        return {
          type: "resource_link",
          uri: fileUri(String(block.path ?? "")),
          name: fileName(String(block.path ?? "")),
        };
      case "audio":
        if (!caps.audio) throw new Error(`${agent} cannot read audio.`);
        blob("audio", block.mimeType, block.data);
        return { type: "audio", mimeType: String(block.mimeType ?? ""), data: String(block.data ?? "") };
      // An embedded resource carries the content itself, for agents that
      // cannot open the path.
      case "resource": {
        if (!caps.embeddedContext)
          throw new Error(`${agent} cannot read file contents sent with a prompt; mention the path instead.`);
        const content = String(block.text ?? "");
        if (content.length > MAX_BLOB) throw new Error(`${block.path} is too large to send with a prompt.`);
        return {
          type: "resource",
          resource: {
            uri: fileUri(String(block.path ?? "")),
            text: content,
            mimeType: str(block.mimeType) ?? "text/plain",
          },
        };
      }
      // ACP has no skill block; agents publish skills as commands.
      case "skill": {
        const input = String(block.input ?? "");
        return { type: "text", text: input ? `/${block.name} ${input}` : `/${block.name}` };
      }
      default:
        throw new Error(`${agent} cannot take a ${block.type ?? "missing"} block in a prompt.`);
    }
  });
}

/// Checks the media type and the size of one base64 blob.
function blob(family: "image" | "audio", mimeType: unknown, data: unknown): void {
  const mime = String(mimeType ?? "");
  if (!mime.startsWith(`${family}/`)) throw new Error(`${mime} is not ${family} content.`);
  // Base64 carries three bytes in four characters; padding makes the
  // estimate at most two bytes high, which no limit turns on.
  if (Math.floor(String(data ?? "").length / 4) * 3 > MAX_BLOB) {
    throw new Error(`this ${family} is larger than ${MAX_BLOB >> 20} MB, which is more than a prompt can carry.`);
  }
}

function fileUri(path: string): string {
  return path.includes("://") ? path : `file://${path}`;
}

function fileName(path: string): string {
  const parts = path.split("/");
  return parts[parts.length - 1] || path;
}

// --- content ---------------------------------------------------------------------------

/// The text an ACP content block contributes to a message.
export function contentText(value: unknown): string {
  const block = object(value);
  switch (block.type) {
    case "text":
      return String(block.text ?? "");
    case "image":
      return "[image]";
    case "audio":
      return "[audio]";
    case "resource_link":
      return str(block.name) ?? String(block.uri ?? "");
    case "resource": {
      const resource = object(block.resource);
      return str(resource.text) ?? str(resource.uri) ?? "";
    }
    default:
      return "";
  }
}

/// A user content block as a Convergence one, so a replayed user message
/// keeps its structure.
export function userBlock(value: unknown): ContentBlock {
  const block = object(value);
  if (block.type === "image" && typeof block.data === "string") {
    return { type: "image", mimeType: String(block.mimeType ?? ""), data: block.data };
  }
  if (block?.type === "resource_link" && typeof block.uri === "string") {
    return { type: "file_ref", path: block.uri.startsWith("file://") ? block.uri.slice("file://".length) : block.uri };
  }
  return { type: "text", text: contentText(block) };
}

// --- tool calls ----------------------------------------------------------------------

export function toolKind(kind: unknown): ToolKind {
  if (kind === "read" || kind === "edit" || kind === "delete" || kind === "move" || kind === "search") return kind;
  if (kind === "execute" || kind === "think" || kind === "fetch" || kind === "switch_mode") return kind;
  return "other";
}

export function toolStatus(status: unknown): ToolStatus {
  switch (status) {
    case "in_progress":
      return "running";
    case "completed":
      return "completed";
    case "failed":
      return "failed";
    default:
      return "pending";
  }
}

/// A terminal the plugin runs on the agent's behalf, as the tool-call UI
/// needs to show it: `{ command, cwd, output, exitCode }`.
export function emptyTerminal(): TerminalView {
  return { command: "", cwd: null, output: "", exitCode: null };
}

const terminalMeta = z.looseObject({
  terminal_info: z.looseObject({ terminal_id: z.string(), cwd: z.string().nullish() }).optional(),
  terminal_output: z.looseObject({ terminal_id: z.string(), data: z.string() }).optional(),
  terminal_exit: z.looseObject({ terminal_id: z.string(), exit_code: z.number().nullish() }).optional(),
});

/// A terminal the agent runs itself and reports in the tool call's `_meta`
/// (the extension Zed reads, used by pi-acp and the Claude and Codex
/// adapters): `terminal_info` opens it, `terminal_output` adds output,
/// `terminal_exit` ends it. The call's content names it like a terminal the
/// client runs (`{ type: "terminal", terminalId }`). Kept in `views`;
/// returns the terminal the update touched.
export function applyTerminalMeta(
  update: { _meta?: unknown; title?: unknown; rawInput?: unknown },
  views: Map<unknown, TerminalView>,
): string | null {
  const parsed = terminalMeta.safeParse(update._meta);
  if (!parsed.success) return null;
  const { terminal_info: opened, terminal_output: output, terminal_exit: exit } = parsed.data;
  if (opened) {
    const input = object(update.rawInput);
    const command = str(input.command) ?? str(update.title) ?? "";
    views.set(opened.terminal_id, { command, cwd: opened.cwd ?? null, output: "", exitCode: null });
  }
  const view = (id: string) => {
    let known = views.get(id);
    if (!known) {
      known = emptyTerminal();
      views.set(id, known);
    }
    return known;
  };
  if (output) view(output.terminal_id).output += output.data;
  if (exit) view(exit.terminal_id).exitCode = exit.exit_code ?? null;
  return opened?.terminal_id ?? output?.terminal_id ?? exit?.terminal_id ?? null;
}

/// `content` of a tool call. `terminals` maps a terminal id to its view.
export function toolContent(
  content: unknown,
  terminals: ReadonlyMap<unknown, TerminalView> = new Map(),
): ToolContent[] {
  const out: ToolContent[] = [];
  for (const value of list(content)) {
    const item = object(value);
    switch (item.type) {
      case "content": {
        const block = object(item.content);
        // An image a tool produced is shown, not described: rendering it
        // as the word "[image]" throws the result away.
        if (block.type === "image" && typeof block.data === "string") {
          const uri = str(block.uri);
          out.push(
            compact({
              type: "image",
              mimeType: String(block.mimeType ?? ""),
              data: block.data,
              path: uri === null ? null : uri.startsWith("file://") ? uri.slice("file://".length) : uri,
            }),
          );
          break;
        }
        const value = contentText(block);
        if (value) out.push({ type: "text", text: value });
        break;
      }
      case "diff":
        if (typeof item.path !== "string") break;
        out.push(
          compact({ type: "diff", path: item.path, oldText: str(item.oldText), newText: str(item.newText) ?? "" }),
        );
        break;
      case "terminal": {
        const view = terminals.get(item.terminalId) ?? emptyTerminal();
        out.push(
          compact({
            type: "terminal",
            command: view.command,
            cwd: view.cwd,
            output: view.output ?? "",
            exitCode: view.exitCode,
          }),
        );
        break;
      }
      default:
        break;
    }
  }
  return out;
}

export function locations(value: unknown): Location[] {
  return list(value)
    .map(object)
    .filter((location) => typeof location.path === "string")
    .map((location) =>
      compact({ path: String(location.path), line: Number.isInteger(location.line) ? Number(location.line) : null }),
    );
}

/// A `tool_call` update as a Convergence `ToolCall`.
export function toolCall(call: AcpToolCall, terminals: ReadonlyMap<unknown, TerminalView> = new Map()): ToolCall {
  return compact({
    id: String(call.toolCallId),
    // ACP has no tool name; without an agent's own, the kind says more
    // than the call id.
    name: str(call.name) ?? str(call.kind) ?? "tool",
    kind: toolKind(call.kind),
    title: str(call.title) ?? "",
    status: toolStatus(call.status),
    input: call.rawInput ?? null,
    output: call.rawOutput ?? null,
    content: toolContent(call.content, terminals),
    locations: locations(call.locations),
  });
}

/// A `ToolCallUpdate` read as a whole tool call: `session/request_permission`
/// describes the call it asks about with the partial shape.
export const toolCallFromUpdate = toolCall;

/// A `tool_call_update` as a patch: only what the agent sent.
export function toolCallUpdate(
  update: AcpToolCall,
  terminals: ReadonlyMap<unknown, TerminalView> = new Map(),
): ToolCallUpdate & { id: string } {
  return compact({
    id: String(update.toolCallId),
    status: typeof update.status === "string" ? toolStatus(update.status) : null,
    // An empty title says nothing new: OpenCode-based agents finish an MCP
    // call with `title: ""`, which would blank the row's name.
    title: text(update.title),
    kind: typeof update.kind === "string" ? toolKind(update.kind) : null,
    input: update.rawInput ?? null,
    // Some agents report the whole result here and send no `content` at
    // all, so a renderer that only reads `content` shows nothing.
    output: update.rawOutput ?? null,
    content: Array.isArray(update.content) ? toolContent(update.content, terminals) : null,
    locations: Array.isArray(update.locations) ? locations(update.locations) : null,
  });
}

// --- runs --------------------------------------------------------------------------------

export function stopReason(reason: unknown): RunOutcome {
  switch (reason) {
    case "cancelled":
      return { status: "cancelled" };
    case "refusal":
      return { status: "failed", message: "the agent refused the request" };
    default:
      return { status: "completed" };
  }
}

/// Why a turn that did not simply end has ended. Both limits stop the
/// agent mid-task; reporting them as a plain completion leaves an answer
/// that stops in the middle and no reason for it.
export function stopNotice(reason: "max_tokens" | "max_turn_requests"): string;
export function stopNotice(reason: unknown): string | null;
export function stopNotice(reason: unknown): string | null {
  switch (reason) {
    case "max_tokens":
      return "The turn stopped because the model reached its output limit. Ask it to continue.";
    case "max_turn_requests":
      return "The turn stopped because the agent reached its limit of requests per turn. Ask it to continue.";
    default:
      return null;
  }
}

const count = (value: unknown): number | null =>
  typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;

/// The usage of a `usage_update`: what the context holds and how large it
/// is. The protocol carries one currency; anything else would be
/// mislabelled in the UI.
export function contextUsage(value: unknown): Usage {
  const update = object(value);
  const cost = object(update.cost);
  const currency = String(cost.currency ?? "");
  const size = count(update.size) ?? 0;
  return compact({
    usedTokens: count(update.used) ?? 0,
    contextWindow: size > 0 ? size : null,
    costUsd:
      typeof cost.amount === "number" && (currency === "" || currency.toLowerCase() === "usd") ? cost.amount : null,
  });
}

/// Token counts for the turn. ACP reports totals for the whole session,
/// which say nothing about what the context holds now; that comes from the
/// last `usage_update` (`context`) when the agent sent one.
export function usage(reportedValue: unknown, context: Usage | null): Usage {
  const reported = object(reportedValue);
  const total = count(reported?.totalTokens) ?? 0;
  const input = count(reported?.inputTokens) ?? 0;
  const output = count(reported?.outputTokens) ?? 0;
  const read = count(reported?.cachedReadTokens);
  const write = count(reported?.cachedWriteTokens);
  return compact({
    usedTokens: context ? context.usedTokens : total,
    contextWindow: context?.contextWindow ?? null,
    costUsd: context?.costUsd ?? null,
    inputTokens: input > 0 ? input : null,
    cachedInputTokens: read === null && write === null ? null : (read ?? 0) + (write ?? 0),
    outputTokens: output > 0 ? output : null,
    reasoningTokens: count(reported?.thoughtTokens),
  });
}

// --- plans, commands ---------------------------------------------------------------------

export function planStatus(status: unknown): "pending" | "in_progress" | "completed" {
  if (status === "in_progress") return "in_progress";
  if (status === "completed") return "completed";
  return "pending";
}

export function plan(value: unknown): { entries: NonNullable<Plan["entries"]> } {
  return {
    entries: list(object(value).entries)
      .map(object)
      .filter((entry) => typeof entry.content === "string")
      .map((entry) =>
        compact({
          content: String(entry.content),
          status: planStatus(entry.status),
          priority:
            entry.priority === "high" || entry.priority === "medium" || entry.priority === "low"
              ? entry.priority
              : null,
        }),
      ),
  };
}

export function commands(update: unknown): SlashCommand[] {
  return list(object(update).availableCommands)
    .map(object)
    .filter((command) => typeof command.name === "string")
    .map((command) =>
      compact({
        name: String(command.name),
        description: str(command.description) ?? "",
        inputHint: str(object(command.input).hint),
        source: "agent",
      }),
    );
}

// --- approvals -----------------------------------------------------------------------------

export function approvalKind(kind: unknown): "allow_once" | "allow_always" | "reject_once" | "reject_always" {
  if (kind === "allow_always" || kind === "reject_once" || kind === "reject_always") return kind;
  return "allow_once";
}

export function approvalOptions(
  options: unknown,
): Array<{ id: string; name: string; kind: "allow_once" | "allow_always" | "reject_once" | "reject_always" }> {
  const out = list(options)
    .map(object)
    .filter((option) => typeof option.optionId === "string")
    .map((option) => ({
      id: String(option.optionId),
      name: String(option.name ?? option.optionId),
      kind: approvalKind(option.kind),
    }));
  out.push({ id: CANCEL_OPTION, name: "Cancel", kind: "reject_once" });
  return out;
}

/// What the user has to read before deciding, from the request's `_meta`.
/// ACP has no field for it, so agents put it in `_meta`; the keys below
/// are the spellings in use. A per-agent namespaced key is read by the
/// quirk table instead.
export function approvalWarning(meta: unknown): string | null {
  const source = object(meta);
  const key = ["warning", "securityWarning", "security_warning"].find(
    (name) => source[name] !== undefined && source[name] !== null,
  );
  if (key === undefined) return null;
  const value = source[key];
  let found = null;
  if (typeof value === "string") found = value;
  else if (value && typeof value === "object") {
    const warning = object(value);
    found = str(warning.message) ?? str(warning.title);
  }
  const trimmed = found?.trim();
  return trimmed ? trimmed : null;
}

/// A widening of the agent's rights that saying yes would grant, from the
/// request's `_meta`.
export function escalation(
  meta: unknown,
): { kind: "grant_root" | "leave_sandbox" | "network"; target?: string } | null {
  const rawValue = object(meta).escalation;
  if (!rawValue || typeof rawValue !== "object") return null;
  const value = object(rawValue);
  const raw = str(value.kind) ?? str(value.type);
  const kinds: Record<string, "grant_root" | "leave_sandbox" | "network"> = {
    grant_root: "grant_root",
    grantRoot: "grant_root",
    leave_sandbox: "leave_sandbox",
    leaveSandbox: "leave_sandbox",
    network: "network",
  };
  const kind = raw === null ? undefined : kinds[raw];
  if (!kind) return null;
  return compact({ kind, target: str(value.target) ?? str(value.path) });
}

/// A permission request that is really a list of choices, as a question.
/// Some agents have no other way to ask one, so they send a permission
/// request whose options are answers rather than a yes and a no.
export function permissionQuestion(id: string, request: UnknownRecord): QuestionRequest {
  const title = str(object(request.toolCall).title) ?? "Choose an option";
  return {
    id,
    message: title,
    fields: [
      {
        id: "option",
        label: title,
        kind: "select",
        options: list(request.options)
          .map(object)
          .filter((option) => typeof option.optionId === "string")
          .map((option) => ({ value: String(option.optionId), label: String(option.name ?? option.optionId) })),
        allowOther: false,
        required: true,
      },
    ],
  };
}

// --- config options --------------------------------------------------------------------------

/// Modes, models and config options as one snapshot. Modes come first
/// because the UI shows them left of the model picker.
export function options(modesValue: unknown, modelsValue: unknown, config: unknown): ConfigOption[] {
  const out: ConfigOption[] = [];
  const modes = object(modesValue);
  const models = object(modelsValue);
  const configs = list(config)
    .map(object)
    .filter((option) => typeof option.id === "string");
  // The older `modes` field is skipped when a config option already owns
  // the `mode` id, or offers the very same choices (an agent that publishes
  // its thinking levels both ways describes one setting twice).
  const modeTaken = configs.some((option) => option.id === MODE_OPTION || sameChoices(option, modes));
  const availableModes = list(modes.availableModes)
    .map(object)
    .filter((mode) => typeof mode.id === "string");
  if (modesValue && availableModes.length && !modeTaken) {
    out.push({
      id: MODE_OPTION,
      name: "Mode",
      category: "mode",
      kind: "select",
      value: String(modes.currentModeId ?? ""),
      choices: availableModes.map((mode) =>
        compact({ value: String(mode.id), name: String(mode.name ?? mode.id), description: str(mode.description) }),
      ),
    });
  }
  // Likewise for `models`, which the current schema replaced with a
  // `model` config option but every ACP 0.11 agent still publishes.
  const modelTaken = configs.some((option) => option.id === MODEL_OPTION || option.category === "model");
  const availableModels = list(models.availableModels)
    .map(object)
    .filter((model) => typeof model.modelId === "string");
  if (modelsValue && availableModels.length && !modelTaken) {
    const choices: ConfigChoice[] = availableModels.map((model) => {
      const levels = effortLevels(model._meta);
      return compact({
        value: String(model.modelId),
        name: String(model.name ?? model.modelId),
        description: str(model.description),
        reasoningLevels: levels.length ? levels : null,
      });
    });
    const current = String(models.currentModelId ?? "");
    out.push({ id: MODEL_OPTION, name: "Model", category: "model", kind: "select", value: current, choices });
    // The levels belong to the chosen model, so the reasoning selector only
    // exists while that model has any.
    const selected = choices.find((choice) => choice.value === current);
    const levels = selected?.reasoningLevels ?? [];
    if (levels.length) {
      out.push({
        id: EFFORT_OPTION,
        name: "Reasoning",
        category: "reasoning",
        kind: "select",
        value: currentEffort(models) ?? levels[0]?.value ?? "",
        choices: levels,
      });
    }
  }
  for (const option of configs) {
    const mapped = configOption(option);
    if (mapped) out.push(mapped);
  }
  return out;
}

/// The reasoning levels a model accepts, from its `_meta`. ACP 0.11 has no
/// field for them, so the agents that offer per-model reasoning put them in
/// `_meta.reasoningEffort`. A single string there is the model's current
/// level, not a list, and gives no picker.
export function effortLevels(meta: unknown): Array<{ value: string; name: string; description?: string }> {
  const source = object(meta);
  const levels = [source.reasoningEffort, source.reasoningEfforts].find(Array.isArray);
  if (!levels) return [];
  const out = [];
  for (const level of levels) {
    if (typeof level === "string") {
      out.push({ value: level, name: level });
      continue;
    }
    if (!level || typeof level !== "object") continue;
    const value = str(level.value) ?? str(level.id) ?? str(level.effort);
    if (value === null) continue;
    out.push(compact({ value, name: str(level.name) ?? value, description: str(level.description) }));
  }
  return out;
}

/// The effort the current model is set to, when the agent says.
export function currentEffort(value: unknown): string | null {
  const models = object(value);
  const model = list(models.availableModels)
    .map(object)
    .find((entry) => entry.modelId === models.currentModelId);
  const effort = object(model?._meta).reasoningEffort;
  return typeof effort === "string" && effort.trim() ? effort.trim() : null;
}

function category(option: UnknownRecord): string {
  switch (option.category) {
    case "mode":
      return "mode";
    case "model":
      return "model";
    case "thought_level":
      return "reasoning";
    default:
      return typeof option.category === "string" ? option.category : String(option.id);
  }
}

/// One ACP `SessionConfigOption`, or `null` for a type this plugin does
/// not know (the rest of the snapshot still goes through).
export function configOption(value: unknown): ConfigOption | null {
  const option = object(value);
  if (typeof option.id !== "string") return null;
  const base = compact({
    id: option.id,
    name: String(option.name ?? option.id),
    description: str(option.description),
    category: category(option),
  });
  if (option.type === "boolean") return { ...base, kind: "toggle", value: option.currentValue === true, choices: [] };
  if (option.type === "select")
    return {
      ...base,
      kind: "select",
      value: String(option.currentValue ?? ""),
      choices: selectChoices(option.options),
    };
  return null;
}

/// A select config option whose values are exactly the mode ids.
function sameChoices(option: UnknownRecord, modesValue: unknown): boolean {
  const modes = object(modesValue);
  if (!modesValue || option.type !== "select") return false;
  const values = selectChoices(option.options)
    .map((choice) => choice.value)
    .sort();
  const ids = list(modes.availableModes)
    .map(object)
    .map((mode) => mode.id)
    .filter((id): id is string => typeof id === "string")
    .sort();
  return ids.length > 0 && values.length === ids.length && values.every((value, index) => value === ids[index]);
}

/// `options` is either a flat list of values or a list of groups.
export function selectChoices(entries: unknown): ConfigChoice[] {
  const choices: ConfigChoice[] = [];
  for (const value of list(entries)) {
    const entry = object(value);
    if (typeof entry.group === "string" && Array.isArray(entry.options)) {
      const label = text(entry.name) ?? entry.group;
      for (const value of entry.options) {
        const option = object(value);
        if (typeof option.value === "string") choices.push(choice(option, label));
      }
    } else if (typeof entry.value === "string") {
      choices.push(choice(entry, null));
    }
  }
  return choices;
}

function choice(option: UnknownRecord, group: string | null): ConfigChoice {
  return compact({
    value: String(option.value),
    name: String(option.name ?? option.value),
    description: str(option.description),
    group,
  });
}

/// The `value` payload of `session/set_config_option`.
export function configValue(value: unknown): { type?: "boolean"; value: string | boolean } {
  if (typeof value === "boolean") return { type: "boolean", value };
  if (typeof value === "string") return { value };
  return { value: JSON.stringify(value) };
}

// --- elicitation -----------------------------------------------------------------------------

/// One question field per property of an elicitation form schema, in the
/// order the schema lists them.
export function questionFields(schema: unknown): QuestionField[] {
  const source = object(schema);
  const required = list(source.required);
  return Object.entries(object(source.properties)).map(([id, property]): QuestionField => {
    const prop = object(property);
    const choices = enumOptions(prop);
    let kind: QuestionField["kind"] = "text";
    if (prop.type === "boolean") kind = "boolean";
    else if (prop.type === "array") kind = "multi_select";
    else if (choices.length) kind = "select";
    return compact({
      id,
      label: str(prop.title) ?? id,
      description: str(prop.description),
      kind,
      options: choices.length ? choices : null,
      allowOther: false,
      required: required.includes(id),
    });
  });
}

/// `enum` + `enumNames`, `oneOf`/`anyOf` with `const`/`title`, or an
/// array's `items`.
function enumOptions(property: UnknownRecord): Array<{ value: string; label: string; description?: string }> {
  const source = property.items && typeof property.items === "object" ? object(property.items) : property;
  const alternatives = Array.isArray(source.oneOf) ? source.oneOf : Array.isArray(source.anyOf) ? source.anyOf : null;
  if (alternatives) {
    return alternatives
      .map(object)
      .filter((entry) => typeof entry.const === "string")
      .map((entry) =>
        compact({
          value: String(entry.const),
          label: str(entry.title) ?? String(entry.const),
          description: str(entry.description),
        }),
      );
  }
  if (!Array.isArray(source.enum)) return [];
  const names = Array.isArray(source.enumNames) ? source.enumNames : [];
  return source.enum
    .map((value, index) => (typeof value === "string" ? { value, label: str(names[index]) ?? value } : null))
    .filter((option): option is { value: string; label: string } => option !== null);
}

/// The user's answers as the elicitation's `content`: numbers where the
/// schema asks for numbers, everything else as the form gave it.
export function elicitationContent(schema: unknown, values: unknown): Record<string, unknown> {
  const properties = object(object(schema).properties);
  const out: Record<string, unknown> = {};
  for (const [id, value] of Object.entries(object(values))) {
    const type = object(properties[id]).type;
    if ((type === "number" || type === "integer") && typeof value === "string" && value.trim()) {
      const number = Number(value);
      out[id] = Number.isFinite(number) ? (type === "integer" ? Math.trunc(number) : number) : value;
    } else {
      out[id] = value;
    }
  }
  return out;
}

// --- transcripts --------------------------------------------------------------------------------

/// Turns the `session/update` notifications `session/load` replays into
/// transcript items.
export class Recorder {
  private readonly items: TranscriptItem[] = [];
  private open: ["user" | "assistant" | "reasoning", number] | null = null;
  readonly terminals = new Map<unknown, TerminalView>();

  push(update: Message): void {
    switch (update.sessionUpdate) {
      case "user_message_chunk": {
        const block = userBlock(update.content);
        const open = this.openUser();
        if (!open) {
          this.items.push({ id: newId("acp-item"), role: "user", blocks: [block] });
          this.open = ["user", this.items.length - 1];
          break;
        }
        const last = open.blocks[open.blocks.length - 1];
        if (last?.type === "text" && block.type === "text") last.text += block.text;
        else open.blocks.push(block);
        break;
      }
      case "agent_message_chunk": {
        const more = contentText(update.content);
        const open = this.openText("assistant");
        if (open) open.text += more;
        else this.startText("assistant", more);
        break;
      }
      case "agent_thought_chunk": {
        const more = contentText(update.content);
        const open = this.openText("reasoning");
        if (open) open.text += more;
        else this.startText("reasoning", more);
        break;
      }
      case "tool_call": {
        if (update.toolCallId === undefined) break;
        this.open = null;
        applyTerminalMeta(update, this.terminals);
        const call = toolCall(update, this.terminals);
        this.items.push({ id: call.id, role: "tool", call });
        break;
      }
      case "tool_call_update":
        if (update.toolCallId === undefined) break;
        this.open = null;
        this.patchTool(update);
        break;
      case "plan":
        this.open = null;
        this.items.push({ id: newId("acp-plan"), role: "plan", plan: plan(update) });
        break;
      default:
        break;
    }
  }

  private patchTool(update: Message): void {
    const touched = applyTerminalMeta(update, this.terminals);
    const patch = toolCallUpdate(update, this.terminals);
    if (touched !== null && patch.content === undefined)
      patch.content = toolContent([{ type: "terminal", terminalId: touched }], this.terminals);
    const existing = [...this.items]
      .reverse()
      .find(
        (item): item is Extract<TranscriptItem, { role: "tool" }> => item.role === "tool" && item.call.id === patch.id,
      );
    if (!existing) {
      this.items.push({
        id: patch.id,
        role: "tool",
        call: compact({
          id: patch.id,
          name: str(update.name) ?? str(update.kind) ?? "tool",
          kind: patch.kind ?? "other",
          title: patch.title ?? "",
          status: patch.status ?? "pending",
          input: patch.input ?? null,
          output: patch.output ?? null,
          content: patch.content ?? [],
          locations: patch.locations ?? [],
        }),
      });
      return;
    }
    const call = existing.call;
    if (patch.status !== undefined) call.status = patch.status;
    if (patch.title !== undefined) call.title = patch.title;
    if (patch.kind !== undefined) call.kind = patch.kind;
    if (patch.input !== undefined) call.input = patch.input;
    if (patch.output !== undefined) call.output = patch.output;
    if (patch.content !== undefined) call.content = patch.content;
    if (patch.locations !== undefined) call.locations = patch.locations;
  }

  private startText(role: "assistant" | "reasoning", text: string): void {
    this.items.push({ id: newId("acp-item"), role, text });
    this.open = [role, this.items.length - 1];
  }

  private openUser(): MutableUserItem | null {
    if (!this.open || this.open[0] !== "user") return null;
    const item = this.items[this.open[1]];
    return item?.role === "user" ? item : null;
  }

  private openText(role: "assistant" | "reasoning"): MutableTextItem | null {
    if (!this.open || this.open[0] !== role) return null;
    const item = this.items[this.open[1]];
    return item?.role === role ? item : null;
  }

  /// The transcript. The instructions this plugin sent ahead of a first
  /// prompt are the plugin's, not the user's, and are left out.
  finish(): TranscriptItem[] {
    const out: TranscriptItem[] = [];
    for (const item of this.items) {
      if (item.role !== "user") {
        out.push(item);
        continue;
      }
      const blocks = item.blocks
        .map((block) => (block.type === "text" ? { ...block, text: stripInstructions(block.text) } : block))
        .filter((block) => block.type !== "text" || block.text !== "");
      if (blocks.length) out.push({ ...item, blocks });
    }
    return out;
  }
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.