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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 44.9 KB | 6 permissions | Listed |
No comments yet.