Official

settings

Built-in settings UI (JavaScript UI plugin).

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/settings@0.2.0

Permissions in 0.2.0

  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.List, enable, disable and reload plugins, install updates, and quit to finish an update
  • Change app settings settings.writeHighChanges the app settings.Change the settings and the theme
  • Manage agents agents.manageHighEnables, disables and signs in agents, and changes their settings.Turn agents on and off, sign in to them and update them
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Import an agent's own sessions as chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List the workspaces an agent's sessions can be imported into
  • Read app settings settings.readLowReads the app settings.Show the settings, and the tools and rules of every plugin
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the settings pageSlots: center

Files

views/plugins.ts9.8 KB
// The Plugins section: one row per plugin with its state, badges, grants
// and the review card that only the native enable flow can open.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Permission, PermissionEntry, PluginSummary, Risk } from "convergence";
import type { SettingsPage } from "../controller.ts";
import { DOT, TITLE_LINE, besideTitle, card, listRow, section, setting, column, fill, row } from "./layout.ts";

// What a permission lets a plugin do, in the words of the native enable
// card; its scope follows in mono.
const PERMISSION_LABELS: Partial<Record<Permission, string>> = {
  "fs.read": "Read files",
  "fs.write": "Change files",
  net: "Connect to",
  process: "Run the program",
  "process.any": "Run any program",
  "native.binaries": "Run its own native programs",
  env: "Read the environment variable",
  "chats.read": "Read your chats and workspaces",
  "chats.control": "Send prompts and change chats",
  "agents.provide": "Provide agents",
  "agents.manage": "Manage agents and their accounts",
  "models.use": "Use your models",
  "tools.provide": "Give agents tools",
  "settings.read": "Read settings",
  "settings.write": "Change settings",
  "git.read": "Read git status and diffs",
  "git.write": "Run git commands",
  terminal: "Open terminals",
  "ui.slots": "Show a panel in",
  "commands.run": "Run other plugins' commands",
  market: "Use the marketplace",
  "plugins.manage": "Manage plugins",
  storage: "Keep its own data",
  "settings.own": "Keep its own settings",
};

// The risk dot: muted for low, softened warning for medium, warning for
// high, danger for dangerous and unsafe.
const RISK: Record<Risk, { label: string; tone: "muted" | "warning" | "danger"; opacity?: number }> = {
  always: { label: "Always allowed", tone: "muted" },
  low: { label: "Low risk", tone: "muted" },
  medium: { label: "Medium risk", tone: "warning", opacity: 0.75 },
  high: { label: "High risk", tone: "warning" },
  dangerous: { label: "Dangerous", tone: "danger" },
  unsafe: { label: "Unsafe", tone: "danger" },
  reserved: { label: "Reserved", tone: "muted" },
};

function pluginBadges(plugin: PluginSummary) {
  return [
    // Official is the ordinary case in this list; only the exceptions get
    // a badge (the review card still names it).
    plugin.claimsOfficial ? htm`<badge variant="warning">Claims an official id</badge>` : null,
    !plugin.official && plugin.source === "local" ? htm`<badge>Local</badge>` : null,
    plugin.source === "marketplace" ? htm`<badge>Marketplace</badge>` : null,
    plugin.basedOn ? htm`<badge>${`Mod of ${plugin.basedOn.id}@${plugin.basedOn.version}`}</badge>` : null,
    // No OS sandbox holds its code on this system (Windows for now):
    // only the permission checks do. Its enable card is held for 2 s.
    !plugin.official && plugin.sandboxed === false ? htm`<badge variant="danger">Sandbox not available</badge>` : null,
  ];
}

// Failed and waiting states are badges; the quiet ones are a word.
function pluginState(plugin: PluginSummary) {
  // `waiting` arrived after the generated type was written; compare the
  // widened state so the badge survives without a cast.
  const state: string = plugin.state;
  if (state === "failed") return htm`<badge variant="danger">Failed</badge>`;
  if (state === "waiting") return htm`<badge variant="warning">Waiting</badge>`;
  if (plugin.enabled && plugin.pending.length) return htm`<badge variant="warning">Needs review</badge>`;
  if (state === "reload_pending") return htm`<badge variant="warning">Reload pending</badge>`;
  // On or off is the switch's to say; only a plugin that has none yet (it
  // is enabled from its review card) says it in words.
  return plugin.enabled || state === "disabled"
    ? null
    : htm`<text style="body" size="xs" tone="faint">Not enabled</text>`;
}

function grantRow(
  ctx: EffectViewContext<object>,
  actions: SettingsPage["Service"],
  plugin: PluginSummary,
  entry: PermissionEntry,
) {
  // A permission only official plugins can have is never granted to
  // another; it shows faint, with the reason why.
  const blocked = entry.risk === "reserved" && !plugin.official;
  const risk = RISK[entry.risk];
  const label = PERMISSION_LABELS[entry.permission] ?? entry.permission;
  const revocable = entry.granted && !plugin.official && entry.risk !== "always";
  return htm`
      <div ...${row} gap="small" padding="small" paddingY="small" alignItems="start">
        <div ...${column} gap="small" padding="small" paddingY=${6}>
          <div ...${column} gap="small" padding="small" width=${7} flexShrink=${0} height=${7} opacity=${risk.opacity}
            background=${DOT[risk.tone]} borderRadius="full" />
        </div>
        <div ...${column} ...${fill} padding="small">
          <div ...${row} gap="small" padding="small" flexWrap="wrap">
            <text style="body" size="sm" weight=${560} tone=${blocked ? "faint" : "foreground"}>${label}</text>
            ${entry.scope ? htm`<text style="mono" size="xs" tone=${blocked ? "faint" : "muted"}>${entry.scope}</text>` : null}
          </div>
          <text style="body" size="xs" tone="muted">${blocked ? "Only official plugins can have this" : (entry.reason ?? "")}</text>
        </div>
        <text style="body" size="xs" tone=${blocked || !entry.granted ? "faint" : risk.tone}>${blocked ? "Refused" : entry.granted ? risk.label : "Not granted"}</text>
        ${
          revocable
            ? htm`<button id=${`revoke-${plugin.name}-${entry.permission}-${entry.scope ?? ""}`} label="Revoke" variant="ghost" small onClick=${() => ctx.run(actions.revoke(plugin.name, entry))} />`
            : null
        }
      </div>`;
}

function pluginRow(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], plugin: PluginSummary) {
  const state = actions.state;
  const open = !!state.openPermissions[plugin.name];
  // A plugin never enabled, or turned off with something new to review,
  // is enabled from its card. A switch is for the rest.
  const reviewFirst = !plugin.enabled && (plugin.state === "not_enabled" || plugin.pending.length > 0);
  const togglePermissions = () => {
    state.openPermissions = { ...state.openPermissions, [plugin.name]: !open };
    ctx.update();
  };
  const reviewButtons = [
    reviewFirst
      ? htm`<button id=${`review-${plugin.name}`} label="Review and enable…" small variant="primary" onClick=${() => ctx.run(actions.review(plugin.name))} />`
      : null,
    plugin.enabled && plugin.pending.length
      ? htm`<button id=${`review-${plugin.name}`} label="Review permissions…" small variant="primary" onClick=${() => ctx.run(actions.review(plugin.name))} />`
      : null,
  ].filter(Boolean);
  // An official plugin's id is `convergence/<name>`; any other id is worth
  // reading, so it follows the version.
  const ownId = plugin.official && plugin.id === `convergence/${plugin.name}`;
  // Two lines: what it is and its controls, then what it does. Permissions
  // and reload are icon buttons on the title line; the permission list
  // opens under the description.
  return listRow(
    besideTitle(
      reviewFirst
        ? null
        : htm`<switch id=${`plugin-${plugin.name}`} label="" checked=${plugin.enabled} onChange=${(e: { value: boolean }) => ctx.run(actions.setPluginEnabled(plugin.name, e.value))} />`,
      36,
    ),
    htm`
        <div ...${column} ...${fill} gap=${2}>
          <div ...${row} gap="small" height=${TITLE_LINE}>
            <text style="body" size="sm" bold>${plugin.name}</text>
            <text style="body" size="xs" tone="faint">${plugin.version}</text>
            ${ownId ? null : htm`<text style="mono" size="xs" tone="faint">${plugin.id}</text>`}
            ${pluginBadges(plugin)}
            <spacer />
            ${pluginState(plugin)}
          </div>
          ${plugin.description ? htm`<text style="body" size="xs" tone="muted">${plugin.description}</text>` : null}
          ${plugin.error ? htm`<code>${plugin.error}</code>` : null}
          ${reviewButtons.length ? htm`<div ...${row} gap="small" paddingY=${4}>${reviewButtons}</div>` : null}
          ${
            open
              ? htm`
              <panel marginTop=${6}>
                ${plugin.permissions.map((entry) => grantRow(ctx, actions, plugin, entry))}
              </panel>`
              : null
          }
        </div>`,
    besideTitle(
      htm`
        <div ...${row} gap=${2}>
          ${
            plugin.permissions.length
              ? htm`<button id=${`permissions-${plugin.name}`} icon="shield-check" variant="ghost" small active=${open} tooltip=${open ? "Hide permissions" : `Permissions (${plugin.permissions.filter((entry) => entry.granted).length} of ${plugin.permissions.length} granted)`} onClick=${togglePermissions} />`
              : null
          }
          <button id=${`reload-${plugin.name}`} icon="rotate-cw" variant="ghost" small tooltip="Reload" onClick=${() => ctx.run(actions.reload(plugin.name))} />
        </div>`,
    ),
  );
}

export function renderPlugins(ctx: EffectViewContext<object>, actions: SettingsPage["Service"]) {
  const p = actions.state.plugins;
  if (!p) return htm`<empty>Loading…</empty>`;
  const providesAgents = (plugin: PluginSummary) =>
    plugin.permissions.some((entry) => entry.permission === "agents.provide");
  const services = p.filter(providesAgents);
  const others = p.filter((plugin) => !providesAgents(plugin));
  return htm`
      <div ...${column} gap="large" padding="small">
        ${section("Interface", "Plugins live in the Plugins workspace. Edit them there; they reload on save. A new plugin runs only once you enable it here.", card(others.map((plugin) => pluginRow(ctx, actions, plugin))))}
        ${services.length ? section("Agents", "Each brings the agents it names.", card(services.map((plugin) => pluginRow(ctx, actions, plugin)))) : null}
      </div>`;
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <324.7 KB7 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.