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

controller.ts28.4 KB
// The marketplace page's effects: loading every section through the host
// and running installs, updates, sign-in and bug reports. State mutations
// stay synchronous so views re-render the same way the JavaScript page did;
// only host answers and chat openings are effects.
import * as Effect from "effect/Effect";
import * as Scope from "effect/Scope";
import * as z from "zod";
import { App, Host, Notify, Plugin, parse, promise } from "convergence/effect";
import type { PluginServices } from "convergence/effect";
import type { AppState } from "convergence";
import { MarketState, ROUTE } from "./state.ts";
import type { Page } from "./state.ts";
import { badgeCount, checkedAttachments, effortChoices, resolveChoice, suggestedPlugins } from "./logic.ts";
import type { AgentChoice } from "./logic.ts";
import {
  accountSchema,
  bugDraftSchema,
  bugPreviewSchema,
  bugReportsSchema,
  categoriesSchema,
  commentsSchema,
  listingSchema,
  loginSchema,
  releaseFileSchema,
  releaseSchema,
  searchAnswerSchema,
  settingsContributesSchema,
  updatesSchema,
} from "./remote.ts";
import type { Listing, MarketUpdateItem, SentReport } from "./remote.ts";
import {
  agentOptions,
  defaultChoice,
  directInstallOn,
  fixWithAgent,
  install as installFlow,
  setDirectInstall,
  update as updateFlow,
} from "./flows.ts";
import { listingUrl } from "./logic.ts";

const messageOf = (error: unknown): string => {
  if (error instanceof Error) return error.message;
  if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
    return error.message;
  return String(error);
};

const marketOpenSchema = z.looseObject({
  id: z.string(),
  version: z.string().nullable().optional(),
  nonce: z.unknown(),
});

export const controller = Effect.fn("Market.controller")(function* () {
  const state = yield* MarketState;
  const host = yield* Host;
  const app = yield* App;
  const notify = yield* Notify;
  const plugin = yield* Plugin;
  const scope = yield* Scope.Scope;
  const redraw = (): void => {
    for (const view of state.views) view.update();
  };
  const fail = Effect.fn("Market.fail")(function* (what: string, error: unknown) {
    yield* notify.error(`${what}: ${messageOf(error)}`);
    redraw();
  });

  // --- data ------------------------------------------------------------------

  const loadAccount = Effect.fn("Market.loadAccount")(function* () {
    yield* host.call("host/market.whoami", {}).pipe(
      Effect.flatMap((raw) => parse("account", accountSchema, raw)),
      Effect.tap((account) =>
        Effect.sync(() => {
          state.account = account;
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          state.account = { signedIn: false as const, error: messageOf(error) };
        }),
      ),
    );
    redraw();
    return state.account;
  });

  const registry = Effect.fn("Market.registry")(function* () {
    return (state.account ?? (yield* loadAccount()))?.registry;
  });

  const loadCategories = Effect.fn("Market.loadCategories")(function* () {
    const raw = yield* host.call("host/market.categories", {}).pipe(Effect.catch(() => Effect.succeed(null)));
    if (raw === null) {
      // An older or unreachable registry: the built-in list stays.
      console.warn("market: the categories could not be read: the registry did not answer");
    } else {
      const parsed = categoriesSchema.safeParse(raw);
      if (parsed.success && parsed.data.categories.length) state.categories = parsed.data.categories;
      else console.warn("market: the categories could not be read: the answer did not parse");
    }
    redraw();
  });

  const search = Effect.fn("Market.search")(function* (more = false) {
    state.searching = true;
    state.searchError = null;
    redraw();
    const page = more ? state.pageNo + 1 : 1;
    yield* host
      .call("host/market.search", {
        query: state.query,
        category: state.category ?? undefined,
        sort: state.sort || undefined,
        page,
      })
      .pipe(
        Effect.flatMap((raw) => parse("market search", searchAnswerSchema, raw)),
        Effect.tap((answer) =>
          Effect.sync(() => {
            state.results = more ? [...(state.results ?? []), ...(answer.results ?? [])] : (answer.results ?? []);
            state.total = answer.total ?? state.results.length;
            state.pageNo = page;
          }),
        ),
        Effect.catch((error) =>
          Effect.sync(() => {
            state.searchError = messageOf(error);
          }),
        ),
      );
    state.searching = false;
    redraw();
  });

  const loadMenus = Effect.fn("Market.loadMenus")(function* () {
    yield* defaultChoice().pipe(
      Effect.tap(({ agents: agents_, options, choice }) =>
        Effect.sync(() => {
          state.menus = { agents: agents_, options, choice };
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          state.menus = {
            agents: [],
            options: [],
            choice: { agentId: null, model: null, effort: null },
            error: messageOf(error),
          };
        }),
      ),
    );
    redraw();
  });

  const chooseAgent = Effect.fn("Market.chooseAgent")(function* (agentId: string) {
    const menus = state.menus;
    if (!menus) return;
    const options = yield* agentOptions(agentId).pipe(Effect.catch(() => Effect.succeed([])));
    state.menus = { ...menus, options, choice: resolveChoice({ agentId }, menus.agents, options) };
    redraw();
  });

  const chooseModel = (model: string): void => {
    const menus = state.menus;
    if (!menus) return;
    const efforts = effortChoices(menus.options, model);
    const effort = efforts.some((c) => c.value === menus.choice.effort)
      ? menus.choice.effort
      : (efforts[0]?.value ?? null);
    state.menus = { ...menus, choice: { ...menus.choice, model, effort } };
    redraw();
  };

  const chooseEffort = (effort: string): void => {
    const menus = state.menus;
    if (!menus) return;
    state.menus = { ...menus, choice: { ...menus.choice, effort } };
    redraw();
  };

  const installableVersions = (listing: Listing): Exclude<Listing["versions"], undefined> =>
    (listing.versions ?? []).filter((v) => !v.yanked && !v.malicious);

  const openListing = Effect.fn("Market.openListing")(function* (id: string, version: string | null = null) {
    state.page = "listing";
    state.listingId = id;
    state.listing = null;
    state.listingError = null;
    state.version = version;
    state.tab = "permissions";
    state.tree = null;
    state.treeError = null;
    state.file = null;
    state.comments = null;
    state.report = null;
    redraw();
    yield* host.call("host/market.listing", { id }).pipe(
      Effect.flatMap((raw) => parse("listing", listingSchema, raw)),
      Effect.tap((listing) =>
        Effect.sync(() => {
          if (state.listingId !== id) return;
          state.listing = listing;
          const installable = installableVersions(listing);
          if (!state.version || !listing.versions?.some((v) => v.version === state.version)) {
            state.version = listing.latestVersion ?? installable[0]?.version ?? listing.versions?.[0]?.version ?? null;
          }
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          if (state.listingId === id) state.listingError = messageOf(error);
        }),
      ),
    );
    if (!state.menus) yield* Effect.forkIn(loadMenus(), scope, { startImmediately: true });
    redraw();
  });

  const loadTree = Effect.fn("Market.loadTree")(function* () {
    const { listingId: id, version } = state;
    state.tree = "loading";
    state.treeError = null;
    redraw();
    yield* host.call("host/market.read", { id: id ?? "", version: version ?? undefined }).pipe(
      Effect.flatMap((raw) => parse("release", releaseSchema, raw)),
      Effect.tap((release) =>
        Effect.sync(() => {
          if (state.listingId === id && state.version === version) state.tree = release;
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          state.tree = null;
          state.treeError = messageOf(error);
        }),
      ),
    );
    redraw();
  });

  const openFile = Effect.fn("Market.openFile")(function* (path: string) {
    const { listingId: id, version } = state;
    state.file = { path, loading: true };
    redraw();
    yield* host.call("host/market.read", { id: id ?? "", version: version ?? undefined, path }).pipe(
      Effect.flatMap((raw) => parse("release file", releaseFileSchema, raw)),
      Effect.tap((file) =>
        Effect.sync(() => {
          if (state.file?.path === path)
            state.file = {
              path: file.path ?? path,
              loading: false,
              text: file.text,
              size: file.size,
              truncated: file.truncated,
              binary: file.binary,
            };
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          if (state.file?.path === path) state.file = { path, error: messageOf(error), loading: false };
        }),
      ),
    );
    redraw();
  });

  const loadComments = Effect.fn("Market.loadComments")(function* () {
    const id = state.listingId;
    state.comments = "loading";
    redraw();
    yield* host.call("host/market.comments", { id: id ?? "" }).pipe(
      Effect.flatMap((raw) => parse("comments", commentsSchema, raw)),
      Effect.tap((comments) =>
        Effect.sync(() => {
          if (state.listingId === id) state.comments = { threads: comments.threads ?? [] };
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          if (state.listingId === id) state.comments = { error: messageOf(error) };
        }),
      ),
    );
    redraw();
  });

  const loadUpdates = Effect.fn("Market.loadUpdates")(function* (check = false) {
    const generation = state.updatesGeneration;
    yield* host.call("host/market.updates", check ? {} : { cached: true }).pipe(
      Effect.flatMap((raw) => parse("updates", updatesSchema, raw)),
      Effect.tap((answer) =>
        Effect.sync(() => {
          // A `market_changed` that came meanwhile is newer than this answer.
          if (state.updatesGeneration === generation) state.updates = answer.updates ?? [];
        }),
      ),
      Effect.tap(() =>
        check
          ? state.updates.length
            ? notify.info(`${badgeCount(state.updates)} plugin updates.`)
            : notify.info("Every plugin is up to date.")
          : Effect.void,
      ),
      Effect.catch((error) => (check ? notify.error(`Could not check for updates: ${messageOf(error)}`) : Effect.void)),
    );
    redraw();
  });

  const load = Effect.fn("Market.load")(function* () {
    state.loaded = true;
    state.direct = yield* directInstallOn().pipe(Effect.catch(() => Effect.succeed(false)));
    yield* loadAccount();
    yield* Effect.forkIn(loadCategories(), scope, { startImmediately: true });
    yield* Effect.forkIn(search(), scope, { startImmediately: true });
  });

  // --- actions ---------------------------------------------------------------

  const busy = Effect.fn("Market.busy")(function* (key: string, work: Effect.Effect<unknown, unknown, PluginServices>) {
    if (state.busy[key]) return;
    state.busy = { ...state.busy, [key]: true };
    redraw();
    // `Effect.ensuring`, not try/finally: a failed `yield*` does not run a
    // JavaScript `finally` block, and a leaked key would pin the button.
    yield* work.pipe(
      Effect.ensuring(
        Effect.sync(() => {
          const { [key]: _removed, ...rest } = state.busy;
          state.busy = rest;
          redraw();
        }),
      ),
    );
  });

  const install = Effect.fn("Market.install")(function* () {
    const { listing, version } = state;
    if (!listing) return;
    const choice: AgentChoice = state.menus?.choice ?? { agentId: null, model: null, effort: null };
    const key = `install:${state.listingId}`;
    yield* busy(
      key,
      installFlow(listing, version, choice).pipe(
        Effect.tap((result) =>
          result.direct
            ? notify.info(`${listing.id} is downloaded, not enabled. Its card is in the chat.`)
            : Effect.void,
        ),
        Effect.catch((error) => fail(`Could not install ${listing.id}`, error)),
      ),
    );
  });

  const runUpdate = Effect.fn("Market.runUpdate")(function* (item: { readonly folder: string; readonly id: string }) {
    if (!state.menus) yield* loadMenus();
    const choice: AgentChoice = state.menus?.choice ?? { agentId: null, model: null, effort: null };
    yield* busy(
      `update:${item.folder}`,
      updateFlow(item, choice).pipe(
        // The list is read again either way: a direct replace clears the
        // row, an agent chat may have merged already.
        Effect.tap(() => loadUpdates()),
        Effect.catch((error) => fail(`Could not update ${item.folder}`, error)),
      ),
    );
  });

  const signIn = Effect.fn("Market.signIn")(function* () {
    yield* busy(
      "sign-in",
      host.call("host/market.login", {}).pipe(
        Effect.flatMap((raw) => parse("sign-in", loginSchema, raw)),
        Effect.tap((code) =>
          Effect.sync(() => {
            state.signIn = { code: code.userCode, url: code.verificationUriComplete ?? code.verificationUri };
          }),
        ),
        Effect.catch((error) => fail("Could not start the sign-in", error)),
      ),
    );
  });

  const signOut = Effect.fn("Market.signOut")(function* () {
    yield* busy(
      "sign-out",
      Effect.gen(function* () {
        yield* host.call("host/market.logout", {}).pipe(Effect.catch((error) => fail("Could not sign out", error)));
        yield* loadAccount();
      }),
    );
  });

  const openSignInPage = Effect.fn("Market.openSignInPage")(function* () {
    const signInState = state.signIn;
    if (!signInState) return;
    const result = yield* promise("openUrl", () => plugin.api.openUrl(signInState.url));
    if (!result.opened)
      yield* notify.warning(
        `Open ${signInState.url} and enter ${signInState.code}${result.reason ? ` (${result.reason})` : ""}.`,
      );
  });

  const sendReport = Effect.fn("Market.sendReport")(function* () {
    const draft = state.report;
    if (!draft) return;
    yield* busy(
      "report",
      Effect.gen(function* () {
        if (!draft.reason.trim()) {
          yield* notify.warning("Say what is wrong first.");
          return;
        }
        yield* host
          .call("host/market.request_report", {
            id: state.listingId ?? "",
            version: state.version ?? undefined,
            reason: draft.reason.trim(),
            files: String(draft.files ?? "")
              .split(/[\n,]+/)
              .map((f) => f.trim())
              .filter(Boolean),
            kind: "user",
          })
          .pipe(
            Effect.tap(() =>
              Effect.sync(() => {
                state.report = null;
              }),
            ),
            Effect.catch((error) => fail("Could not prepare the report", error)),
          );
      }),
    );
  });

  // --- bug reports (MARKETPLACE.md §14) --------------------------------------

  const loadReports = Effect.fn("Market.loadReports")(function* () {
    const reports = yield* host.call("host/market.bug_reports", {}).pipe(
      Effect.flatMap((raw) => parse("bug reports", bugReportsSchema, raw)),
      Effect.map((answer) => answer.reports ?? []),
      Effect.catch(() => Effect.succeed([])),
    );
    state.reports = reports;
    redraw();
  });

  // Opens the form: from the palette, Settings, `cvg bug`, or this page.
  // `plugins` and `text` fill it in.
  const openBugForm = Effect.fn("Market.openBugForm")(function* (args?: {
    readonly plugins?: readonly string[];
    readonly text?: string;
    readonly chatId?: string | null;
  }) {
    const { plugins = [], text = "", chatId = null } = args ?? {};
    state.page = "bugs";
    yield* app.route(ROUTE);
    state.bug = {
      loading: true,
      error: null,
      draft: null,
      chosen: new Set(plugins),
      fullSource: new Set(),
      screenshots: [],
      text,
      email: "",
      preview: null,
      previewing: false,
      previewError: null,
      unchecked: new Set(),
      open: new Set(),
      requestId: null,
      fixAfter: false,
      sent: null,
    };
    redraw();
    if (!state.menus) yield* Effect.forkIn(loadMenus(), scope, { startImmediately: true });
    yield* Effect.forkIn(loadReports(), scope, { startImmediately: true });
    yield* host.call("host/market.bug_draft", chatId ? { chatId } : {}).pipe(
      Effect.flatMap((raw) => parse("bug draft", bugDraftSchema, raw)),
      Effect.tap((draft) =>
        Effect.sync(() => {
          const bug = state.bug;
          if (!bug) return;
          bug.draft = draft;
          for (const name of suggestedPlugins(draft.plugins)) bug.chosen.add(name);
          // A plugin named by id (the web's "Report a bug") is chosen by folder.
          for (const draftPlugin of draft.plugins ?? [])
            if (bug.chosen.has(draftPlugin.id)) {
              bug.chosen.delete(draftPlugin.id);
              bug.chosen.add(draftPlugin.name);
            }
        }),
      ),
      Effect.catch((error) =>
        Effect.sync(() => {
          if (state.bug) state.bug.error = messageOf(error);
        }),
      ),
    );
    if (state.bug) state.bug.loading = false;
    redraw();
    yield* Effect.forkIn(refreshPreview(), scope, { startImmediately: true });
  });

  // What the chosen plugins, sources and screenshots would attach, with the
  // full content (the host removed secrets and home paths).
  const refreshPreview = Effect.fn("Market.refreshPreview")(function* () {
    const bug = state.bug;
    if (!bug) return;
    const key = JSON.stringify([[...bug.chosen].sort(), [...bug.fullSource].sort(), bug.screenshots]);
    bug.previewKey = key;
    bug.previewing = true;
    bug.previewError = null;
    redraw();
    yield* host
      .call("host/market.bug_preview", {
        plugins: [...bug.chosen],
        fullSource: [...bug.fullSource],
        screenshots: bug.screenshots,
      })
      .pipe(
        Effect.flatMap((raw) => parse("bug preview", bugPreviewSchema, raw)),
        Effect.tap((preview) =>
          Effect.sync(() => {
            if (state.bug === bug && bug.previewKey === key) {
              bug.preview = preview;
              // Unchecked by default: what the host marks so (a third party's diff).
              for (const attachment of preview.attachments ?? [])
                if (!attachment.defaultOn && !bug.seen?.has(attachment.id)) bug.unchecked.add(attachment.id);
              bug.seen = new Set((preview.attachments ?? []).map((a) => a.id));
            }
          }),
        ),
        Effect.catch((error) =>
          Effect.sync(() => {
            if (state.bug === bug && bug.previewKey === key) bug.previewError = messageOf(error);
          }),
        ),
      );
    if (state.bug === bug && bug.previewKey === key) bug.previewing = false;
    redraw();
  });

  const togglePlugin = Effect.fn("Market.togglePlugin")(function* (name: string, on: boolean) {
    const bug = state.bug;
    if (!bug) return;
    if (on) bug.chosen.add(name);
    else {
      bug.chosen.delete(name);
      bug.fullSource.delete(name);
    }
    yield* Effect.forkIn(refreshPreview(), scope, { startImmediately: true });
  });

  const toggleSource = Effect.fn("Market.toggleSource")(function* (name: string, on: boolean) {
    const bug = state.bug;
    if (!bug) return;
    if (on) bug.fullSource.add(name);
    else bug.fullSource.delete(name);
    yield* Effect.forkIn(refreshPreview(), scope, { startImmediately: true });
  });

  const addScreenshots = Effect.fn("Market.addScreenshots")(function* () {
    const { paths } = yield* promise("pickImages", () => plugin.api.pickImages());
    if (!paths.length) return;
    const bug = state.bug;
    if (!bug) return;
    bug.screenshots = [...new Set([...bug.screenshots, ...paths])].slice(0, 5);
    yield* Effect.forkIn(refreshPreview(), scope, { startImmediately: true });
  });

  // "Review and send…": the native card shows everything that is sent;
  // only its Send button sends. With `fixAfter`, the fix chat opens once
  // the report is sent.
  const reviewReport = Effect.fn("Market.reviewReport")(function* (fixAfter = false) {
    const bug = state.bug;
    if (!bug) return;
    yield* busy(
      "bug-review",
      Effect.gen(function* () {
        if (!bug.text.trim()) {
          yield* notify.warning("Say what happened and what you expected first.");
          return;
        }
        if (!bug.chosen.size) {
          yield* notify.warning("Choose one or more related plugins.");
          return;
        }
        yield* host
          .call("host/market.request_bug_report", {
            text: bug.text.trim(),
            plugins: [...bug.chosen],
            contactEmail: !bug.draft?.signedIn && bug.email.trim() ? bug.email.trim() : undefined,
            attachments: checkedAttachments(bug.preview?.attachments, bug.unchecked),
            fullSource: [...bug.fullSource],
            screenshots: bug.screenshots,
            kind: "user",
          })
          .pipe(
            Effect.tap(({ requestId }) =>
              Effect.sync(() => {
                bug.requestId = requestId;
                bug.fixAfter = fixAfter;
              }),
            ),
            Effect.catch((error) => fail("Could not prepare the bug report", error)),
          );
      }),
    );
  });

  const fixReport = Effect.fn("Market.fixReport")(function* (report: SentReport) {
    if (!state.menus) yield* loadMenus();
    const choice: AgentChoice = state.menus?.choice ?? { agentId: null, model: null, effort: null };
    yield* busy(
      `fix:${report.id}`,
      fixWithAgent(report, choice).pipe(
        Effect.catch((error) => fail(`Could not open the fix chat for #${report.number}`, error)),
      ),
    );
  });

  const attachFix = Effect.fn("Market.attachFix")(function* (report: SentReport) {
    yield* busy(
      `attach:${report.id}`,
      host
        .call("host/market.request_attach_fix", { report: report.id })
        .pipe(Effect.catch((error) => fail(`Nothing to attach to #${report.number}`, error))),
    );
  });

  // --- events ------------------------------------------------------------------

  const onBugForm = Effect.fn("Market.onBugForm")(function* (event: {
    readonly plugins?: readonly string[];
    readonly text?: string;
  }) {
    yield* openBugForm({ plugins: event.plugins ?? [], text: event.text ?? "" });
  });

  const onBugReport = Effect.fn("Market.onBugReport")(function* (event: {
    readonly requestId: string;
    readonly reportId: string;
    readonly number: number;
  }) {
    yield* loadReports();
    const bug = state.bug;
    if (!bug || bug.requestId !== event.requestId) return;
    bug.sent = state.reports.find((report) => report.id === event.reportId) ?? {
      id: event.reportId,
      number: event.number,
      text: bug.text,
      plugins: [],
    };
    bug.requestId = null;
    redraw();
    if (bug.fixAfter) yield* fixReport(bug.sent);
  });

  const onSecurityResolved = (id: string, outcome: string): void => {
    if (state.bug?.requestId === id && outcome !== "sent") {
      state.bug.requestId = null;
      redraw();
    }
  };

  const onMarketChanged = (updates: readonly MarketUpdateItem[]): void => {
    state.updatesGeneration++;
    state.updates = updates;
    redraw();
  };

  const onMarketAccount = Effect.fn("Market.onMarketAccount")(function* (event: {
    readonly signedIn: boolean;
    readonly error?: string;
  }) {
    state.signIn = null;
    if (event.error) yield* notify.error(`Marketplace sign-in: ${event.error}`);
    else if (event.signedIn) yield* notify.info("Signed in to the marketplace.");
    yield* loadAccount();
  });

  // A listing installed, enabled or removed: its Install row changes.
  const refreshListing = Effect.fn("Market.refreshListing")(function* () {
    if (state.page === "listing" && state.listing && !state.busy.listing) {
      const id = state.listingId;
      yield* host.call("host/market.listing", { id: id ?? "" }).pipe(
        Effect.flatMap((raw) => parse("listing", listingSchema, raw)),
        Effect.tap((listing) =>
          Effect.sync(() => {
            if (state.listingId === listing.id) state.listing = listing;
            redraw();
          }),
        ),
        Effect.catch(() => Effect.void),
      );
    }
  });

  const setDirect = Effect.fn("Market.setDirect")(function* (on: boolean) {
    state.direct = on;
    yield* setDirectInstall(on).pipe(Effect.catch((error) => fail("Could not save the setting", error)));
    redraw();
  });

  const selectPage = Effect.fn("Market.selectPage")(function* (page: Page) {
    state.page = page;
    if ((page === "updates" || page === "bugs") && !state.menus)
      yield* Effect.forkIn(loadMenus(), scope, { startImmediately: true });
    if (page === "bugs") yield* Effect.forkIn(loadReports(), scope, { startImmediately: true });
    redraw();
  });

  const setListingTab = (tab: string): void => {
    state.tab = tab;
    redraw();
  };

  const backToBrowse = Effect.fn("Market.backToBrowse")(function* () {
    state.page = "browse";
    redraw();
    if (!state.results) yield* Effect.forkIn(search(), scope, { startImmediately: true });
  });

  const toggleRoute = Effect.fn("Market.toggleRoute")(function* (open: boolean) {
    yield* app.route(open ? "chat" : ROUTE);
  });

  const openListingSite = Effect.fn("Market.openListingSite")(function* () {
    const id = state.listingId;
    if (!id) return;
    const site = yield* registry();
    yield* promise("openUrl", () => plugin.api.openUrl(listingUrl(site, id)));
  });

  const followApp = Effect.fn("Market.followApp")(function* (next: AppState) {
    const shared = next.shared ?? {};
    const link = marketOpenSchema.safeParse(shared.marketOpen);
    if (link.success && link.data.id && link.data.nonce !== state.linkNonce) {
      state.linkNonce = link.data.nonce;
      yield* Effect.forkIn(openListing(link.data.id, link.data.version ?? null), scope, { startImmediately: true });
    }
    if (next.route === ROUTE && !state.loaded) yield* Effect.forkIn(load(), scope, { startImmediately: true });
  });

  // The settings a release's permissions name (`${settings.key}`), from
  // its plugin.json, so the list shows each one's title and value
  // (MARKETPLACE.md §15.3 rule 6). Read once per release: the view runs the
  // returned fetch with `ctx.run`, keeping rendering pure.
  const settingsOf = (
    permissions: unknown,
  ): { settings: unknown; fetch: Effect.Effect<void, unknown, PluginServices> | null } => {
    const { listingId: id, version } = state;
    if (!id || !version || !JSON.stringify(permissions ?? {}).includes("${settings."))
      return { settings: null, fetch: null };
    const key = `${id}@${version}`;
    if (state.settingsSchema?.key === key) return { settings: state.settingsSchema.settings, fetch: null };
    state.settingsSchema = { key, settings: null };
    const fetch = Effect.fn("Market.settingsOf.fetch")(function* () {
      const raw = yield* host
        .call("host/market.read", { id, version, path: "plugin.json" })
        .pipe(Effect.catch(() => Effect.succeed(null)));
      if (!raw || state.settingsSchema?.key !== key) return;
      const fileText = z.looseObject({ text: z.string().optional() }).safeParse(raw);
      const text = (fileText.success ? fileText.data.text : undefined) ?? "";
      try {
        const parsed = settingsContributesSchema.safeParse(JSON.parse(text));
        state.settingsSchema.settings = parsed.success ? (parsed.data.contributes?.settings ?? null) : null;
      } catch {
        state.settingsSchema.settings = null;
      }
      redraw();
    });
    return { settings: null, fetch: fetch() };
  };

  return {
    state,
    redraw,
    loadAccount,
    registry,
    loadCategories,
    search,
    loadMenus,
    chooseAgent,
    chooseModel,
    chooseEffort,
    installableVersions,
    openListing,
    loadTree,
    openFile,
    loadComments,
    loadUpdates,
    load,
    busy,
    install,
    runUpdate,
    signIn,
    signOut,
    openSignInPage,
    sendReport,
    loadReports,
    openBugForm,
    refreshPreview,
    togglePlugin,
    toggleSource,
    addScreenshots,
    reviewReport,
    fixReport,
    attachFix,
    onBugForm,
    onBugReport,
    onSecurityResolved,
    onMarketChanged,
    onMarketAccount,
    refreshListing,
    followApp,
    settingsOf,
    setDirect,
    selectPage,
    setListingTab,
    backToBrowse,
    toggleRoute,
    openListingSite,
  };
});

export type Controller = Effect.Success<ReturnType<typeof controller>>;

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.