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

flows.ts8.7 KB
// What the marketplace page does with a click: open a scan chat for an
// install, install directly, update, sign in, fix a bug report. Every chat
// opens in the Plugins workspace with the agent, model and effort chosen
// next to the button, which start at the last combination used there.
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as z from "zod";
import { App, Host, Notify, Storage } from "convergence/effect";
import type { Chat } from "convergence";
import type { AgentChoice, ChoiceInput } from "./logic.ts";
import {
  choiceOfChat,
  fixPrompt,
  optionChanges,
  resolveChoice,
  runsAnything,
  savedChoiceSchema,
  scanPrompt,
  updatePrompt,
} from "./logic.ts";
import type { Listing } from "./remote.ts";

export const CHOICE_KEY = "agentChoice";
export const DIRECT_KEY = "directInstall";

// A choice the menus cannot start from: no agent is enabled to run the chat.
export class FlowFailed extends Data.TaggedError("FlowFailed")<{ readonly message: string }> {}

// The Plugins workspace, cached per load like the JavaScript version's closure.
let workspaceCache: { readonly workspaceId: string; readonly path: string } | null = null;

export const pluginsWorkspace = Effect.fn("Market.pluginsWorkspace")(function* () {
  if (!workspaceCache) workspaceCache = yield* (yield* Host).call("host/market.workspace", {});
  return workspaceCache;
});

export const agents = Effect.fn("Market.agents")(function* () {
  return (yield* (yield* Host).call("host/agents.list", {})).agents;
});

export const agentOptions = Effect.fn("Market.agentOptions")(function* (agentId: string | null) {
  if (!agentId) return [];
  const { workspaceId } = yield* pluginsWorkspace();
  return (yield* (yield* Host).call("host/agents.options", { agentId, workspaceId })).options;
});

// The last combination used in the Plugins workspace: the one this page
// saved, else the newest chat there.
export const savedChoice = Effect.fn("Market.savedChoice")(function* () {
  const storage = yield* Storage;
  const saved = yield* storage.get(CHOICE_KEY, savedChoiceSchema).pipe(
    // A choice an older build wrote may not parse; fall back to the newest chat.
    Effect.catchTag("ParseFailed", () => Effect.succeed(undefined)),
  );
  if (saved?.agentId) return saved;
  const host = yield* Host;
  const { workspaceId } = yield* pluginsWorkspace();
  const { chats } = yield* host.call("host/chats.list", { workspaceId });
  const newest = chats
    .filter((chat) => !chat.archived)
    .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0];
  if (!newest) return null;
  return choiceOfChat(newest, yield* agentOptions(newest.agentId));
});

// The choice the menus start with: always one that exists now.
export const defaultChoice = Effect.fn("Market.defaultChoice")(function* () {
  const [list, saved] = yield* Effect.all([agents(), savedChoice()], { concurrency: "unbounded" });
  const agentId = list.some((agent) => agent.id === saved?.agentId) ? saved?.agentId : list[0]?.id;
  const options = yield* agentOptions(agentId ?? null);
  return { agents: list, options, choice: resolveChoice(saved, list, options) };
});

export const saveChoice = Effect.fn("Market.saveChoice")(function* (choice: AgentChoice) {
  yield* (yield* Storage).set(CHOICE_KEY, choice);
});

export interface OpenChat {
  readonly title: string;
  readonly prompt: string | null;
  readonly choice: AgentChoice;
  readonly beforeSend?: (chat: Chat) => Effect.Effect<void, unknown, Host>;
}

// A chat in the Plugins workspace with the chosen agent, model and effort;
// with a prompt it starts at once. The user sees it. `beforeSend` runs
// once the chat exists and before its first prompt.
export const openChat = Effect.fn("Market.openChat")(function* (args: OpenChat) {
  const { title, prompt, choice, beforeSend } = args;
  if (!choice.agentId) return yield* new FlowFailed({ message: "Enable an agent in Settings > Agents first." });
  const host = yield* Host;
  const app = yield* App;
  const { workspaceId } = yield* pluginsWorkspace();
  const chat = yield* host.call("host/chats.create", { workspaceId, agentId: choice.agentId, title });
  const { options } = yield* host.call("host/chats.options", { chatId: chat.id });
  for (const [optionId, value] of optionChanges(options, choice)) {
    yield* host.call("host/chats.set_option", { chatId: chat.id, optionId, value });
  }
  yield* saveChoice(choice);
  if (beforeSend) yield* beforeSend(chat);
  if (prompt) yield* host.call("host/chats.send", { chatId: chat.id, text: prompt });
  yield* app.select(chat.id, workspaceId);
  return chat;
});

// Install (the default): a scan chat. The agent reads the plugin
// remotely, installs it only when it is safe, and asks for the card.
export const installWithScan = Effect.fn("Market.installWithScan")(function* (
  listing: Listing,
  version: string | null,
  choice: AgentChoice,
) {
  return yield* openChat({
    title: `Install ${listing.id}`,
    prompt: scanPrompt({ id: listing.id, version, listing, installed: listing.installed ?? null }),
    choice,
  });
});

export const directInstallOn = Effect.fn("Market.directInstallOn")(function* () {
  const on = yield* (yield* Storage).get(DIRECT_KEY, z.boolean()).pipe(
    // Anything but a stored boolean means off, as before.
    Effect.catchTag("ParseFailed", () => Effect.succeed(undefined)),
  );
  return on === true;
});

export const setDirectInstall = Effect.fn("Market.setDirectInstall")(function* (on: boolean) {
  yield* (yield* Storage).set(DIRECT_KEY, on);
});

const installPermissions = (listing: Listing, version: string | null): unknown =>
  listing.versions?.find((v) => v.version === version)?.permissions ?? listing.permissions;

// Install directly: no scan. The download lands not enabled, and the
// enable card waits in a small chat of the Plugins workspace. Never for
// a plugin that can run any program.
export const installDirect = Effect.fn("Market.installDirect")(function* (
  listing: Listing,
  version: string | null,
  choice: AgentChoice,
) {
  if (runsAnything(installPermissions(listing, version)))
    return yield* new FlowFailed({
      message: `${listing.id} can run any program, so it is installed only after a scan.`,
    });
  const chat = yield* openChat({ title: `Install ${listing.id}`, prompt: null, choice });
  const installed = yield* (yield* Host).call("host/market.install", {
    id: listing.id,
    version: version ?? undefined,
    chatId: chat.id,
  });
  return { chat, installed };
});

// The Install button: directly when the setting is on and the plugin
// cannot run any program, else through a scan chat.
export const install = Effect.fn("Market.install")(function* (
  listing: Listing,
  version: string | null,
  choice: AgentChoice,
) {
  if ((yield* directInstallOn()) && !runsAnything(installPermissions(listing, version))) {
    return { direct: true as const, ...(yield* installDirect(listing, version, choice)) };
  }
  return { direct: false as const, chat: yield* installWithScan(listing, version, choice) };
});

// The Update button of one plugin: an unmodified official plugin is
// replaced at once; anything else opens a chat with the difference.
export const update = Effect.fn("Market.update")(function* (
  item: { readonly folder: string; readonly id: string },
  choice: AgentChoice,
) {
  const host = yield* Host;
  const answer = yield* host.call("host/market.update", { name: item.folder });
  if ("updated" in answer && answer.updated) {
    yield* (yield* Notify).info(`${item.folder} is updated to ${answer.to}.`);
    return { updated: true as const, answer };
  }
  if (!("needsAgent" in answer && answer.needsAgent))
    return yield* new FlowFailed({ message: `Could not update ${item.folder}: the host gave no update.` });
  const chat = yield* openChat({ title: `Update ${answer.id ?? item.id}`, prompt: updatePrompt(answer), choice });
  return { updated: false as const, chat, answer };
});

// "Try to fix it with an agent" (§14.2): a chat in the Plugins workspace
// with the report's text, linked to the report before its first turn, so
// a turn that changes an official plugin offers "Attach fix to report #N".
export const fixWithAgent = Effect.fn("Market.fixWithAgent")(function* (
  report: {
    readonly id: string;
    readonly number: number | string;
    readonly text: string;
    readonly plugins?: readonly {
      readonly id: string;
      readonly version?: string;
      readonly folder?: string;
      readonly official?: boolean;
    }[];
  },
  choice: AgentChoice,
) {
  return yield* openChat({
    title: `Fix bug report #${report.number}`,
    prompt: fixPrompt(report),
    choice,
    beforeSend: (chat) =>
      Effect.gen(function* () {
        yield* (yield* Host).call("host/market.link_fix_chat", { chatId: chat.id, report: report.id });
      }).pipe(Effect.asVoid),
  });
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.