A plugin is a manifest-bundled feature. It extends the plugin.json shape Copse
already loads (skills + MCP) with the remaining slots, and the plugin registry owns
its lifecycle. This document describes the landed plugin layer: the manifest shape,
the registry, atomic enable/disable, and the level-2 declarative panel
contribution.
Want to install or author a plugin? Start with
docs/adding-a-plugin.md — Settings → Customise links there.
The design source of truth is
docs/plans/hooks-and-feature-packs.md
("Plugins" + the two-capability-tiers and disable-never-breaks-history decisions); on conflict, that plan wins — update it in
the same PR.
The declarative manifest is a superset of the plugin.json shape, published as a
JSON schema at schemas/copse-pack.schema.json
($id https://copse.dev/schemas/copse-pack.schema.json). The TypeScript
contract is PluginManifest in
packages/agent/src/plugins/plugin-manifest.ts.
plugin manifest
├── stability stable | experimental (missing user values fail safe to experimental)
├── tools native + ACP-safe tool names (first-party) or an MCP config path (user plugins)
├── models selected-plugin whole-thread routes shown in the thread picker
├── browser exact visible-browser origins for selected-plugin model routes
├── runtime shared isolated entrypoint for selected-plugin executable behavior
├── hooks command-hook declarations (user plugins); first-party function hooks are typed runtime contributions
├── prompt skills / steering blocks (with trust framing: trusted vs untrusted)
├── ui contributions — level 1 (cards) / 2 (named panel slot) / 3 (real renderer view)
├── followUps bubbles suggested above the composer; the offer-shaped alternative to a modal
├── settings plugin-scoped schema, rendered generically in Settings
└── storage namespaced state; survives disable
Following the two-capability-tiers decision (VS Code's built-in-extensions model), first-party and
user plugins share the manifest, registry, Settings surface, and disable semantics.
First-party plugins additionally supply typed runtime contributions —
AgentStreamChunk emission, live loop-state access, real renderer views — which
is why the executable bits (function hooks, native tool registrations) live on
the runtime RegisteredPlugin.contributions, not in the serializable manifest.
The one user-code exception is an explicitly selected plugin's isolated
shared runtime; it never imports code into Electron main.
The isolated runtime also supports declared runtime.hooks registrations through
registerHook and explicit host invocation. Hook-only runtimes are accepted. This
is the SDK/protocol stage: the agent's canonical event fire sites do not invoke
these external functions yet, and their results cannot alter agent behavior.
See the SDK contract
for declaration matching, cancellation, and the deliberately narrow handler context.
pluginManifestFromCursorJson() maps a Cursor-shaped plugin.json into a
PluginManifest (a user plugin): the existing top-level skills / mcpServers
fields fold into the plugin slots (mcpServers → tools.mcpServers). The
Settings plugin list that renders settings landed in the plugin-list UI phase (see
Plugin list UI below).
Host disk discovery is wired for Agent Plugins
packages, per Stage A of
docs/plans/agent-plugins-migration.md. A
directory under the Copse plugin root (~/.copse/plugins/, or COPSE_PLUGINS_DIR)
with a root plugin.json registers as a user plugin row:
parseAgentPluginManifest()
(packages/agent/src/plugins/agent-plugin-manifest.ts)
parses the envelope and the extensions["dev.copse"] block, and
discoverUserPlugins()
(src/main/services/plugins/discover-user-plugins.ts)
walks the root and feeds the registry.
Discovery validates but never activates: a discovered plugin gets a Settings
row and the enable/disable lifecycle, and is seeded off the first time it is
seen. Its command hooks and MCP servers are parsed and held, not registered into
the live agent loop — that wiring is deliberately separate work. Skills and MCP
from a Cursor-installed plugin still load via Cursor plugin discovery (see
docs/adding-a-plugin.md), which remains a distinct
compatibility path.
An explicitly selected plugin directory is an ordinary user plugin, not a new
trust tier. Its copse-pack.json can declare tool names under tools.provides,
whole-thread model metadata under models.provides, or both, implemented by one
versioned runtime.
The host validates the manifest/tree, rejects symlinks and escaping entrypoints,
and computes a deterministic content hash. Adding the directory is the current
explicit opt-in; behavior-derived installation consent is future product work.
At startup the host re-hashes the source, materializes and validates a
content-addressed snapshot, and executes only that snapshot in a standalone
API-v1 worker. The sandbox denies direct network and filesystem writes, and the
worker can register only the tools, models and function hooks declared by the manifest.
Function-hook registration supports explicit host invocation through the isolated
SDK, with opaque JSON results; canonical event fire sites do not invoke these
registrations automatically. This is distinct from the existing command-hook
path. See the SDK contract.
Without the OS sandbox, execution fails closed.
Selected-plugin models are whole-thread routes, like remote and ACP agents rather
than task-role models. Enabled routes appear in the footer picker. The host sends
the current prompt, bounded validated current-turn images when declared, and a
bounded text-only prior-conversation handoff. The worker also receives only the
concrete durable-session operations get, set, and delete; the host binds
them mechanically to the active plugin and Copse thread. This lets a plugin reuse an
external conversation id and use the transcript handoff only when rebuilding a
missing session. Disabling the plugin removes its routes from new selections while
preserving the stored session and a disabled current selection's display metadata.
P4 adds one concrete host-owned gateway: a selected plugin with
browser.origins can operate plugin-and-thread owned tabs in the visible browser
panel through explicit open, navigate, tabs, snapshot, click, type,
and upload operations. Exact-origin checks happen before navigation and every
interaction; redirects outside the declaration fail closed. Tabs use the
panel's persistent interactive profile and are revealed to the user, so this is
appropriate for workflows that need an existing site login or occasional human
interaction. The bridge never returns a webview/Electron object to the worker.
Image upload converts validated base64 image data supplied by the model handler
into an in-page File and assigns it to a referenced file input with
DataTransfer, then dispatches input/change; it never opens a native file
chooser. Each upload operation is capped at eight files and 8 MB decoded total.
There is still no direct worker network, generic host.call, arbitrary IPC, or
user-plugin renderer code. Renderer contribution machinery remains deferred until
a concrete behavior needs it rather than landing as an unused slot.
A plugin can suggest a follow-up bubble — one of the chips above the
composer, next to "Changes" — rather than interrupting with a modal
(decision 21). Each declaration
carries an id, a label, and two optional fields the host resolves:
| field | values | meaning |
|---|---|---|
action |
prompt (default) |
the click sends the declaration's prompt to the agent |
model-compare |
the click opens the comparison model picker, then runs the comparison | |
when |
always (default) |
offered whenever the plugin is enabled |
workspace-changes |
offered only while the working tree has uncommitted changes |
when is a bounded vocabulary rather than a predicate, so the manifest stays
plain JSON: a plugin names a condition the host already computes for its own
deterministic bubbles, and no plugin code runs at the end of every turn.
A host action is first-party only. It drives app UI (and, for
model-compare, spends money) without passing through the agent, so
pluginManifestFromPluginJson forces a discovered manifest's action back to
prompt, and PluginRegistry.register throws
InvalidFollowUpContributionError for a non-first-party plugin that
contributes one. It also rejects a prompt bubble with no prompt text, which
would be a dead click.
The shipped example is copse.model-comparison, whose "Compare models" bubble
is gated on workspace-changes because the reviewers read the working diff.
Its picker names all three models before anything runs, which is what lets the
run behind it skip the spend approval and completion chime: the click was the
decision. Agent-initiated and auto-on-review runs still prompt.
Disabling the plugin drops its bubbles from activeFollowUps() in the same
atomic flag flip as its tools and hooks.
PluginRegistry
(packages/agent/src/plugins/plugin-registry.ts)
groups every plugin's contributions by plugin id and owns the lifecycle:
- Grouping —
all()/grouping()enumerate plugins (Settings, P3); theactive*()getters (activeToolNames,activeBlockingHooks,activeAsyncHooks,activePromptBlocks,activeUiContributions,activeBrowserOrigins,activeCapabilities,activePermissions,activeAcpToolNames) return the contributions of enabled plugins only, for new work. - ACP tools — a first-party plugin may declare
tools.acpToolsas the subset of itstools.nativeentries safe to execute through Copse's authenticated localhost bridge for external ACP agents. Registration rejects user-plugin, non-native, and missing-runtime declarations. The bridge intersects the enabled declaration with the liveToolRegistry, so disabling the plugin or removing its credential-gated tool revokes ACP exposure immediately. - Capabilities — a plugin may declare named capability flags: pure
cross-cutting behaviour with no tool/hook/prompt/panel (e.g. the DevTools
shortcut, or the MCP-UI canvas's rendering gate — that plugin pairs its
capability with one conditional steering hook). Any subsystem reads one
through the single
isCapabilityActive(name)seam instead of a scatteredgetSettingcheck; a capability is active iff some enabled plugin declares it. - Permissions — a plugin may declare the authority it opens: a sandbox
relaxation it may request, such as
copse.background-tasksdeclaringloopback-bindfor a task that binds a localhost port. The permission-gate resolves it throughisPermissionDeclared(name)before offering or honouring the grant, so the authority exists only while the owning plugin is enabled — the same flag flip that unregisters the plugin's tools revokes it. The declaration also feeds the Settings enumeration and the install-time review. - Stability and default enablement — every first-party manifest declares
stableorexperimental; missing user-plugin values fail safe to experimental. Settings shows the status before enablement.createFirstPartyPluginRegistry()seeds every plugin enabled, thenEXPERIMENTAL_FIRST_PARTY_PLUGIN_IDSderives the off-by-default set from those declarations and writes it intopluginDisabledon a profile that has never had one. OncepluginDisabledexists it is the user's own and is never re-seeded. - Atomic enable/disable —
disable(id)flips a single flag, so every one of a plugin's contribution kinds drops from the active getters at once: tools leave the model tool list, hooks stop firing, prompt blocks drop out, UI stops mounting for new content, capabilities turn off, declared permissions are revoked. There is no partial state. - Storage survives disable —
storage(id)is a namespaced bag that is never cleared on disable (decision 17), like a disabled browser extension's data. - Dynamic selected-source reconciliation —
unregister(id)removes a selected plugin whose directory disappears or changes, while retaining its namespaced storage. The refreshed manifest is validated before registration.
First-party plugins are the static list in
packages/agent/src/plugins/first-party-plugins.ts.
The initial plugin phase originally shipped a skeleton copse.noop plugin (empty contributions) to
prove the lifecycle end-to-end before the pilot todos plugin landed in the todos-plugin phase.
Once real first-party plugins exercised the same lifecycle, the skeleton was
removed from the shipped list. The plugin seam is wired into
createHookRegistry — a plugin's hooks register through the same registry the
loop uses — so a disabled plugin removes them from new work without touching loop
code.
The default-off copse.automations plugin also exercises the first-party level-3
boundary: its manifest declares settings-plugin-detail and app-dialog views
that share the same project editor, plus namespaced storage, while the Electron host supplies the local clock/thread-store service
and the shipped renderer submits due prompts through the interactive agent controller.
See docs/plans/automations.md for the deliberately narrow
desktop-online cron prototype and its durable-supervisor boundary.
The default-off copse.parallel-search plugin is another level-3 first-party
integration. It contributes the native parallel_search tool and calls
Parallel's Search API directly rather than running an MCP server. Registration
requires both an enabled plugin and a configured PARALLEL_API_KEY (or encrypted
key saved in Settings). Its detail view keeps the secret outside the generic
plugin-settings snapshot and states the network, billing, and ZDR boundary. The
tool is also declared in tools.acpTools, so HTTP-MCP-capable external ACP
agents can invoke the same direct API implementation through Copse's native-tool
bridge. See
docs/parallel-search.md.
A plugin contributes a level-2 panel by declaring a UI contribution at
level: 2 with a panel: { kind: 'list' | 'tree', header?, ariaLabel? } slot,
and by emitting panel_update chunks
(PanelData) whose contents the
host renders with a generic list/tree component
(createPackPanelEl). Each panel_update
replaces the panel's contents, matching ACP plan's whole-list-per-update
semantics — which is why a list panel is one adapter away from cross-client
rendering.
panel_updateis now part ofAgentStreamChunk, so first-party function hooks emit it viaFunctionHookContext.emitChunk(external command hooks never seeemitChunk, so a user plugin cannot smuggle typed feature chunks — decision 15, pinned bycommand-hooks-cannot-emit-feature-chunks.test.ts).- The chunk carries
packId+contributionIdso two plugins cannot collide on the same declared panel slot. - Level 2 is deliberately declarative: no freeform React from a plugin at this level. Real renderer views are level 3, first-party only (VS Code built-in-extensions model).
- The data model is seeded from the
todo_update↔ ACPplanmapping:todosToPanelListData()projects aTodoItem[]intoPanelListDatawith the same header + rows +"N/M done"summary the current todo panel shows, so the P4 todos plugin can switch fromtodo_updatetopanel_updatewith no visible regression. Cancelled todos remain in durable thread state but are omitted from the projection; an all-cancelled plan therefore stays hidden. - Registration enforces the invariant: a
level: 2contribution must declare itspanelshape.PluginRegistry.registerthrowsInvalidPanelContributionErroron a missing decl (mechanical, not "please remember").activePanelContributions()returns each enabled plugin's level-2 panels paired with their owning plugin id; disabling the plugin drops it in one action alongside the plugin's tools / hooks / prompt / other UI.
Every registered plugin — first-party and user — shows up in Settings → Customise
as a row with an enable/disable toggle, an enumeration of what the plugin
contributes (tools / hooks / prompt blocks / UI panels), and any plugin-scoped
settings the manifest declares. This is the about:addons of Copse.
Customise holds one plugin list, not several. Plugins installed through Cursor
(~/.cursor/plugins/) appear in the same list, badged Cursor and read-only —
Cursor owns their lifecycle, so there is no toggle, but leaving them out would
have meant two places to look for the answer to "what is extending Copse".
Worktrees are not customisation and live under Settings → Storage.
Settings → MCP servers stays its own section and is the complete account of what Copse talks to over MCP, from any source. Two things make it a lens rather than a status list:
-
Every row says who asked for it.
McpServerStatus.origin(src/shared/types/mcp.ts) classifies the config source asuser/project/plugin/curated/built-in, computed inmcp-registry.tswhere the roots of each are known. Onlyprojectis coloured: a.mcp.jsonis the one that arrives with a checkout. -
Declarations nothing is running are disclosed, not omitted.
PluginService.declaredMcpServers()reports every server a discovered plugin'smcp.jsonnames, with the reason it is inert — the plugin is off, or Copse does not start plugin MCP servers yet. These rows carry no toggle: a disclosure that offered a switch would imply Copse could start the server. -
Shared registry. The host's
PluginService(src/main/services/plugins/plugin-service.ts) owns onePluginRegistryand installs it as the default provider (setDefaultPluginRegistryinpackages/agent/src/plugins/default-plugin-registry.ts) socreateHookRegistryreads through the same instance the Settings UI toggles. Toggling a plugin in Settings flips the shared registry's flag atomically (P1 contract) — every one of the plugin's contribution kinds drops from the active getters at once. -
Persistence. The disable set is stored as a
readonly string[]under thepluginDisabledkey (same pattern asmcpDisabledServers); each plugin's settings values live underplugin.<pluginId>.settingsas a plain record keyed by field id. Writes go throughstorageUpdateso concurrent toggles cannot drop each other's change. Applied to the registry at boot before the provider is installed, so a plugin the user turned off stays off across relaunches. -
IPC.
packs:list/packs:setEnabled/packs:setSetting(renderer surface:api.plugins.*in the preload). Values are validated toboolean/number/string ≤ 8192so a compromised renderer cannot stuff arbitrary payloads. -
Selected-plugin IPC. Source selection is owned by the main process's native directory picker.
packs:addSourceexposes only a host-validated candidate; the renderer never supplies a path directly. -
Renderer. The Settings dialog gains a
Pluginsnav section (src/renderer/views/settings-dialog.ts). Each row shows the plugin's name + version + trust and stability badges, an enable toggle, a description, contribution chips (Tools × N,Models × N,Browser origins × N,Hooks × N,Prompt blocks × N,UI × N) with the underlying identifiers in a hover title, and generic form fields for the manifest'ssettingsschema. Disabled rows are visually greyed viaplugin-row-disabledso the effect of the toggle is immediately visible; per-plugin settings stay editable so a user can configure a disabled plugin before re-enabling it. Selected-directory rows additionally show their source path and content hash. -
Projection helper.
packages/agent/src/plugins/plugin-summary.tsis the puresummarizePlugins(registry, readSetting)that projects the shared registry + a per-key reader into the plain-dataPluginSummaryOutsnapshots the renderer consumes (mirrored tosrc/shared/types/plugins.tsfor the IPC crossing). Values are coerced to the declared kind on projection, falling back to the manifest default when storage is corrupt.
Disabling a plugin never breaks history. Transcript rendering resolves from shipped renderer code + spine data, never from live registration state. Opening an old conversation shows a disabled plugin's tool calls, cards, and panels exactly as they ran — Copse ships the code; only registration for new work is removed.
This is mechanical, not a convention: the PluginRegistry exposes no method
that maps a historical record through live enablement, and the shipped renderers
(hookCardFromSpineLine, getToolDisplayName) take only spine data — never the
registry. The invariant is pinned by
src/main/services/plugins/history-never-consults-live-registration.test.ts:
disabling a plugin drops its hooks/tools from the active set while a historical
hook_run card and tool-call display render byte-identically. The atomicity of
disable is pinned by
packages/agent/src/plugins/enable-disable-atomicity.test.ts.
- Plugin manifest types, registry, first-party plugin definitions, and the level-2
panel data model + seed transforms live in
packages/agent/src/plugins/(Electron-free — first-party function hooks receive app services via context, never import them). - The plugin runtime SDK and host protocol live in
packages/plugin-sdk/src/(@copse/plugin-sdk, host-free): the plugin-tool wire protocol (plugin-tool-protocol.ts), the author-facing SDK (plugin-tool-sdk.ts), the isolated worker entry (plugin-tool-worker.ts), runtime source discovery and reviewed snapshots (plugin-tool-source.ts,plugin-tool-snapshot.ts), the browser-service and model-turn adapters, and the MCP server configuration schema and types (mcp-schema.ts,mcp-config.ts,mcp-types.ts). The profile root the snapshots live under arrives throughconfigurePluginSdk;src/main/services/plugins/andsrc/main/services/mcp/re-export the package and keep the host: the registry service, the sandboxed tool host and controller, disk discovery, the browser panel, the MCP registry, and custom tools. - The generic list/tree panel renderer lives in
src/renderer/views/plugin-panel.tsand consumes the Electron-freePanelDatatypes across the package boundary. - Host persistence of the enable/disable set + plugin-scoped settings values
(
electron-storeunderpluginDisabledandplugin.<pluginId>.settings), the sharedPluginRegistrysingleton, and the Settings plugin list UI landed in P3 (src/main/services/plugins/plugin-service.ts+src/renderer/views/settings-dialog.ts). Host disk-discovery of user plugins into that registry is still outstanding.
docs/adding-a-plugin.md— practical install / authoring guide (linked from Settings → Customise)docs/forced-planning.md—copse.forced-planning, the first plugin born as a plugin rather than extracted, and theresolvePackSettingseam it introduceddocs/parallel-search.md— direct Parallel Search API plugin, credentials, permissions, and ZDR boundarydocs/plans/hooks-and-feature-packs.md— design source of truth (Plugins, the two-capability-tiers and disable-never-breaks-history decisions)docs/cursor-plugins.md— the plugin manifest the plugin manifest extendsdocs/hooks.md— the hook registry a plugin's hooks register throughdocs/supply-chain-security.md— trust boundaries for skills and MCP