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