Official

market

The plugin marketplace: browse, install through an agent's scan, update and publish plugins, and the marketplace tools of agents in the Plugins workspace.

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/market@0.1.0

Permissions in 0.1.0

  • Marketplace marketOfficial onlyUses the marketplace APIs. Only official plugins can have it.Search, read, install, update and publish marketplace plugins, and sign in to the marketplace
  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.Ask for the enable card of a plugin an agent installed
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Open the chat in the Plugins workspace where an agent scans, installs or updates a plugin
  • Provide agent tools tools.provideMediumGives tools to agents.Give agents in the Plugins workspace the marketplace tools
  • Read chats chats.readMediumReads your transcripts and chat lists.Find the agent, model and effort last used in the Plugins workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the marketplace button, with its update count, and the marketplace pageSlots: railcenter

Files

tools.ts18.4 KB
// The marketplace tools agents in the Plugins workspace get
// (plugins/AGENTS.md, Marketplace). Reading and downloading need no
// approval: a downloaded plugin is not enabled and cannot run. Enabling,
// publishing, reporting, bug reports and fixes only put a native card in
// the chat; the user decides there.
//
// Each tool's Zod input schema derives the same JSON Schema the
// hand-written one had: the adapter sends `z.toJSONSchema(input)` as the
// tool's `inputSchema`.
import * as Effect from "effect/Effect";
import * as z from "zod";
import { Host, Tools, parse } from "convergence/effect";
import type { EffectTool } from "convergence/effect";
import type { ToolContext } from "convergence";
import { TOOL_SCOPE, parseSpec, runsAnything } from "./logic.ts";
import { bugPreviewSchema, listingSchema } from "./remote.ts";
import type { Listing } from "./remote.ts";

const requiredText = (description: string): z.ZodString => z.string().describe(description);
const string = (description: string): z.ZodOptional<z.ZodString> => requiredText(description).optional();
const idProperty = z.string().describe("The listing id, publisher/name (for example alice/pr-tools)");
const versionProperty = string("A version (semver); the latest installable one when left out");

// The host methods the tools call.
export const METHODS = {
  search: "host/market.search",
  read: "host/market.read",
  similar: "host/market.similar",
  install: "host/market.install",
  update: "host/market.update",
  requestPublish: "host/market.request_publish",
  requestReport: "host/market.request_report",
  bugPreview: "host/market.bug_preview",
  requestBugReport: "host/market.request_bug_report",
  requestAttachFix: "host/market.request_attach_fix",
} as const;

// Any tool result: the adapter passes values through without an output
// schema, exactly as the JavaScript tools returned them.
type AnyResult = z.ZodUnknown;

const searchInput = z.object({
  query: z.string().describe("Words to search for").optional(),
  category: z.string().describe("official, agents, tools, interface, git, terminal, web or mods").optional(),
  sort: z.string().describe("relevance, downloads, rating, recent or name").optional(),
  page: z.number().int().min(1).optional(),
});
const readInput = z.object({
  id: idProperty,
  version: versionProperty,
  path: string("A file of the package, for example main.js"),
});
const similarInput = z.object({ id: idProperty });
const installInput = z.object({ id: idProperty, version: versionProperty });
const updateInput = z.object({ name: requiredText("The plugin's folder name") });
const requestEnableInput = z.object({ name: requiredText("The plugin's folder name") });
const publishInput = z.object({
  name: requiredText("The plugin's folder name"),
  visibility: z.enum(["public", "unlisted", "private", "shared"]).describe("What the form starts with").optional(),
  share: z.array(z.string()).describe("Usernames or emails, for shared").optional(),
});
const reportInput = z.object({
  id: idProperty,
  version: requiredText("The version you read"),
  reason: requiredText("What the plugin does wrong, in plain words, with the evidence"),
  files: z.array(z.string()).describe("Evidence: file:line, for example main.js:42"),
});
const bugReportInput = z.object({
  text: string("What happened and what was expected, in plain words, with the steps to see it"),
  plugins: z.array(z.string()).describe("The related plugins: folder names or ids (one or more)"),
  attachments: z
    .array(z.string())
    .describe("The attachment ids the user chose (from the preview); all the default ones when left out")
    .optional(),
  fullSource: z
    .array(z.string())
    .describe("Third-party or local plugins whose full source the user chose to attach")
    .optional(),
  contactEmail: string("Only when the user is not signed in and gave one"),
  preview: z.boolean().describe("List what would be attached; sends nothing and shows no card").optional(),
});
const attachFixInput = z.object({
  report: requiredText("The report's id (bug_…) or #number"),
  folders: z
    .array(z.string())
    .describe("The changed plugin folders; every changed marketplace plugin when left out")
    .optional(),
});

// What an agent sees of one attachment the report would send: enough to
// name it to the user and choose, not the whole text.
function attachmentBrief(attachment: {
  readonly id: string;
  readonly kind: string;
  readonly title: string;
  readonly size: number;
  readonly defaultOn?: boolean;
  readonly removed?: readonly unknown[];
  readonly text?: string;
}): {
  readonly id: string;
  readonly kind: string;
  readonly title: string;
  readonly size: number;
  readonly sentByDefault: boolean;
  readonly secretsRemoved: number;
  readonly start: string | null;
} {
  const text = typeof attachment.text === "string" ? attachment.text : null;
  return {
    id: attachment.id,
    kind: attachment.kind,
    title: attachment.title,
    size: attachment.size,
    sentByDefault: Boolean(attachment.defaultOn),
    secretsRemoved: (attachment.removed ?? []).length,
    start: text === null ? null : text.slice(0, 600),
  };
}

function requireId(id: unknown): string {
  const spec = parseSpec(id);
  if (!spec || spec.version) throw new Error(`\`id\` must be publisher/name, not ${JSON.stringify(id)}`);
  return spec.id;
}

function requireChat(ctx: ToolContext, what: string): string {
  if (!ctx.chatId)
    throw new Error(
      `${what} needs the chat this call came from, and the agent did not name it; ask the user to use the Marketplace page instead`,
    );
  return ctx.chatId;
}

// One search result, short: what an agent needs to choose.
function brief(result: Listing): {
  readonly id: string;
  readonly description: string;
  readonly publisher: string | undefined;
  readonly official: boolean;
  readonly latestVersion: string | undefined;
  readonly downloads: unknown;
  readonly rating: { readonly average?: number; readonly count?: number } | undefined;
  readonly visibility: string | undefined;
} {
  return {
    id: result.id,
    description: typeof result.description === "string" ? result.description : "",
    publisher: result.publisher?.name,
    official: Boolean(result.publisher?.verified),
    latestVersion: result.latestVersion,
    downloads: result.downloads ?? 0,
    rating: result.rating,
    visibility: result.visibility,
  };
}

const searchAnswerSchema = z.looseObject({
  results: z.array(z.unknown()).optional(),
  page: z.number().optional(),
  pageSize: z.number().optional(),
  total: z.number().optional(),
});

export const searchTool: EffectTool<typeof searchInput, AnyResult> = {
  name: "market_search",
  title: "Search the marketplace",
  description:
    "Search the Divergence plugin marketplace. Returns listings (id, publisher, official flag, latest version, downloads, rating). Official plugins are published by `convergence`.",
  input: searchInput,
  annotations: { readOnlyHint: true, openWorldHint: true },
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.searchTool.run")(function* (input: z.infer<typeof searchInput>, _ctx: ToolContext) {
    const host = yield* Host;
    const raw = yield* host.call(METHODS.search, {
      query: input.query ?? "",
      category: input.category,
      sort: input.sort,
      page: input.page,
    });
    const answer = yield* parse("market_search", searchAnswerSchema, raw);
    const results: Listing[] = [];
    for (const item of answer.results ?? []) results.push(yield* parse("market_search.result", listingSchema, item));
    return { results: results.map(brief), page: answer.page ?? 1, pageSize: answer.pageSize, total: answer.total ?? 0 };
  }),
};

export const readTool: EffectTool<typeof readInput, AnyResult> = {
  name: "market_read",
  title: "Read a marketplace plugin",
  description:
    "Read a published release from its verified package, without installing it: without `path` the file tree, permissions, basedOn and publisher; with `path` one file's text. Nothing is saved and nothing runs. Use it to scan a plugin before it is installed.",
  input: readInput,
  annotations: { readOnlyHint: true, openWorldHint: true },
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.readTool.run")(function* (input: z.infer<typeof readInput>, _ctx: ToolContext) {
    return yield* (yield* Host).call(METHODS.read, {
      id: requireId(input.id),
      version: input.version || undefined,
      path: input.path || undefined,
    });
  }),
};

export const similarTool: EffectTool<typeof similarInput, AnyResult> = {
  name: "market_similar",
  title: "Similar marketplace names",
  description:
    "Publishers and plugins with names like this one, with creation dates, install counts and the verified flag: check it for impersonation before an install.",
  input: similarInput,
  annotations: { readOnlyHint: true, openWorldHint: true },
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.similarTool.run")(function* (input: z.infer<typeof similarInput>, _ctx: ToolContext) {
    return yield* (yield* Host).call(METHODS.similar, { id: requireId(input.id) });
  }),
};

export const installTool: EffectTool<typeof installInput, AnyResult> = {
  name: "market_install",
  title: "Install a marketplace plugin",
  description:
    "Verify, download and extract a release into the Plugins workspace. The plugin is NOT enabled and cannot run. Install only after a scan found it safe; then call market_request_enable with the folder this answers, and the user decides on the enable card.",
  input: installInput,
  annotations: { openWorldHint: true },
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.installTool.run")(function* (input: z.infer<typeof installInput>, _ctx: ToolContext) {
    const installed = yield* (yield* Host).call(METHODS.install, {
      id: requireId(input.id),
      version: input.version || undefined,
    });
    return {
      ...installed,
      next: `Installed in the folder ${installed.folder}, not enabled. Call market_request_enable { name: "${installed.folder}" } so the user can review it on the enable card.`,
      runsAnyProgram: runsAnything(installed.permissions),
    };
  }),
};

export const updateTool: EffectTool<typeof updateInput, AnyResult> = {
  name: "market_update",
  title: "Update an installed plugin",
  description:
    "Update an installed plugin by its folder name. An unmodified official plugin is replaced at once. Anything else answers `needsAgent` with the versions, the local changes and the permission changes: read the new version with market_read, scan the difference, merge it into the folder, and call market_request_enable only if the permissions changed.",
  input: updateInput,
  annotations: { openWorldHint: true },
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.updateTool.run")(function* (input: z.infer<typeof updateInput>, _ctx: ToolContext) {
    if (!input.name) throw new Error("`name` is the plugin's folder");
    return yield* (yield* Host).call(METHODS.update, { name: input.name });
  }),
};

export const requestEnableTool: EffectTool<typeof requestEnableInput, AnyResult> = {
  name: "market_request_enable",
  title: "Ask the user to enable a plugin",
  description:
    "Show the native enable card for a plugin folder in this chat (or the permission card when it is enabled and asks for more). It grants nothing: only the user's answer on the card does. Answers `requestId`, or null when there is nothing to review.",
  input: requestEnableInput,
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.requestEnableTool.run")(function* (
    input: z.infer<typeof requestEnableInput>,
    ctx: ToolContext,
  ) {
    if (!input.name) throw new Error("`name` is the plugin's folder");
    const { requestId } = yield* (yield* Host).call("host/plugins.request_enable", {
      name: input.name,
      chatId: ctx.chatId || undefined,
    });
    return requestId
      ? {
          requestId,
          shown: ctx.chatId ? "in this chat" : "over the window",
          note: "The user decides on the card. Do not ask again unless the user asks.",
        }
      : {
          requestId: null,
          note: `${input.name} has nothing to review: it is enabled with every permission it asks for.`,
        };
  }),
};

export const publishTool: EffectTool<typeof publishInput, AnyResult> = {
  name: "market_publish",
  title: "Offer to publish a plugin",
  description:
    "Only when the user asked you to publish a plugin: shows the native publish row in this chat, with the plugin and its permissions. The user publishes from the row (visibility and share list); you cannot publish yourself. `basedOn` is filled in by the app. Do not call it on your own after changing a plugin: the app offers the row by itself.",
  input: publishInput,
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.publishTool.run")(function* (input: z.infer<typeof publishInput>, ctx: ToolContext) {
    if (!input.name) throw new Error("`name` is the plugin's folder");
    const chatId = requireChat(ctx, "market_publish");
    const { requestId } = yield* (yield* Host).call(METHODS.requestPublish, {
      name: input.name,
      chatId,
      visibility: input.visibility,
      share: input.share,
    });
    return { requestId, note: "The publish row is below your response. The user publishes from it, or not." };
  }),
};

export const reportTool: EffectTool<typeof reportInput, AnyResult> = {
  name: "market_report",
  title: "Report a plugin to the moderators",
  description:
    "Only when you are highly confident that a marketplace plugin is malicious or has a security vulnerability. Name the files and lines that prove it. Shows a native report card with exactly what is sent; nothing is sent until the user sends it.",
  input: reportInput,
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.reportTool.run")(function* (input: z.infer<typeof reportInput>, ctx: ToolContext) {
    if (!input.reason || !String(input.reason).trim()) throw new Error("`reason` explains the problem");
    if (!Array.isArray(input.files) || !input.files.length)
      throw new Error("`files` names the files and lines that show the problem");
    const { requestId } = yield* (yield* Host).call(METHODS.requestReport, {
      id: requireId(input.id),
      version: input.version || undefined,
      reason: String(input.reason).trim(),
      files: input.files.map(String),
      kind: "agent",
      chatId: ctx.chatId || undefined,
    });
    return { requestId, note: "The report card shows what is sent. The user sends it, or not." };
  }),
};

export const bugReportTool: EffectTool<typeof bugReportInput, AnyResult> = {
  name: "market_bug_report",
  title: "Prepare a bug report",
  description: [
    "Only when you are highly confident that a bug is real and you can name the plugin (plugins/AGENTS.md, Bug reports). First call it with `preview: true` to list the attachments it would send (nothing is sent, no card).",
    "Then show the user the report text and the attachments you propose, ask them to check the text and to choose what to attach, and only then call it without `preview`.",
    "It shows a native card with the full preview of everything that is sent; nothing is sent until the user sends it there.",
  ].join(" "),
  input: bugReportInput,
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.bugReportTool.run")(function* (input: z.infer<typeof bugReportInput>, ctx: ToolContext) {
    const host = yield* Host;
    if (!Array.isArray(input.plugins) || !input.plugins.length)
      throw new Error("`plugins` names one or more related plugins");
    if (input.preview) {
      const raw = yield* host.call(METHODS.bugPreview, { plugins: input.plugins, fullSource: input.fullSource ?? [] });
      const answer = yield* parse("market_bug_report.preview", bugPreviewSchema, raw);
      return {
        plugins: answer.plugins,
        attachments: (answer.attachments ?? []).map(attachmentBrief),
        next: "Show the user the report text and these attachments, ask them to check the text and choose, then call market_bug_report again without `preview`, with the `attachments` they chose.",
      };
    }
    if (!input.text || !String(input.text).trim()) throw new Error("`text` says what happened and what was expected");
    const chatId = requireChat(ctx, "market_bug_report");
    const { requestId } = yield* host.call(METHODS.requestBugReport, {
      text: String(input.text).trim(),
      plugins: input.plugins,
      attachments: Array.isArray(input.attachments) ? input.attachments.map(String) : undefined,
      fullSource: Array.isArray(input.fullSource) ? input.fullSource.map(String) : [],
      contactEmail: input.contactEmail || undefined,
      kind: "agent",
      chatId,
    });
    return {
      requestId,
      note: "The card below shows everything that is sent. The user sends it, unchecks attachments, or cancels.",
    };
  }),
};

export const attachFixTool: EffectTool<typeof attachFixInput, AnyResult> = {
  name: "market_attach_fix",
  title: "Attach a fix to a bug report",
  description:
    "After you fixed a bug by changing an official or marketplace plugin here and the user confirmed the fix works: shows the native attach card for the report, with the diff of each changed plugin against its base version, the permissions that changed and, on the first contribution, the contributor agreement. The user attaches it there. The app usually shows this row by itself after the turn; call it when the row is gone or the fix is for another report.",
  input: attachFixInput,
  scope: TOOL_SCOPE,
  run: Effect.fn("Market.attachFixTool.run")(function* (input: z.infer<typeof attachFixInput>, ctx: ToolContext) {
    if (!input.report || !String(input.report).trim()) throw new Error("`report` is the bug report's id or #number");
    const chatId = requireChat(ctx, "market_attach_fix");
    const answer = yield* (yield* Host).call(METHODS.requestAttachFix, {
      report: String(input.report).trim(),
      folders: Array.isArray(input.folders) ? input.folders.map(String) : [],
      chatId,
    });
    return {
      requestId: answer.requestId,
      plugins: answer.plugins,
      note: "The attach card is below your response. The user attaches the fix from it, or not.",
    };
  }),
};

// The ten marketplace tools, in the order the JavaScript version registered them.
export const registerMarketTools = Effect.fn("Market.registerTools")(function* () {
  const tools = yield* Tools;
  yield* tools.register(searchTool);
  yield* tools.register(readTool);
  yield* tools.register(similarTool);
  yield* tools.register(installTool);
  yield* tools.register(updateTool);
  yield* tools.register(requestEnableTool);
  yield* tools.register(publishTool);
  yield* tools.register(reportTool);
  yield* tools.register(bugReportTool);
  yield* tools.register(attachFixTool);
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <344.9 KB6 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.