ADR-004: Plugin model
- Status: Accepted
- Date: 2026-06-02
- Deciders: johnny4young
Context
The core ships a lean, universal driving surface — lifecycle, snapshot/find, interaction, read, wait, eval (opt-in), observe, dialog, expect. Every differentiation capability on the roadmap (trace/replay, IPC capture, production-package validation, network, clock, storage, macOS-native surfaces) is heavier, more specialised, or more security-sensitive, and not every consumer needs it. Bundling all of it into the core would:
- bloat the install (signing/notarization toolchains, a trace artifact format + viewer, interception machinery) for users who just want to drive an app;
- push the always-on tool count well past the point where LLM tool-selection accuracy
degrades (the m13v / laststance data behind ADR-007: ergonomics drive accuracy, and a
bloated
tools/listdilutes selection); - widen the default security surface (a server that can always intercept network or validate signed packages is a bigger target than one that opts in).
The answer is a plugin model: the core stays lean; optional capabilities ship as separate
@electron-stagewright/plugin-* packages that a consumer loads explicitly. ADR-006 already
anticipated this (registerPluginCodes('production', …) → production.NOTARIZATION_FAILED)
and deferred the full design here. This ADR locks the contract before any plugin package
exists, so trace/IPC/production all register tools and codes the same way.
Decision
1. A plugin is data plus optional lifecycle hooks
Consistent with ADR-008's "a tool is data, not a function", a plugin is a plain
StagewrightPlugin object: a name (namespace), a version, optional coreVersionRange,
optional tools (authored with SHORT names), optional errorCodes (authored with BARE
keys), and optional async setup / teardown hooks. A plugin package's module exports one.
2. Tools are namespaced <plugin>_<tool> (underscore)
Plugin authors write short tool names (start); the loader registers them as
<plugin>_<tool> (trace_start). Plugin names must match ^[a-z][a-z0-9]*$ and may not be
the reserved core namespace electron, so plugin tools never collide with the core's
electron_* surface and live in the same flat snake_case MCP tool namespace.
This diverges deliberately from an earlier sketch (electron-stagewright/production:verify_signature,
with / and :): several MCP hosts restrict tool names to [A-Za-z0-9_-], and //: risk
breaking them. Underscore is universally safe and visually consistent with electron_*.
Collisions are prevented by the loader (duplicate-name rejection) rather than by punctuation.
3. Error codes are namespaced <plugin>.CODE (dot)
As locked by ADR-006, plugin error codes surface as <plugin>.CODE
(trace.BUFFER_FULL). Plugin authors declare BARE SCREAMING_SNAKE_CASE keys; the loader
registers each as <plugin>.<KEY> in a runtime registry (registerPluginErrorCodes)
separate from the core's closed compile-time ErrorCode union — the union cannot be
extended dynamically, so plugin codes live alongside it and the envelope builder resolves a
code's http/retryable/hint from either source via lookupErrorCodeDefinition. Plugin
handlers emit them with makePluginError('<plugin>.CODE', …) — handlers RETURN the
envelope, they do not throw it (StagewrightError accepts core codes only).
The runtime registry is reference-counted: it is process-global, but tests and
embedders may create more than one server with the same plugin loaded, so registering an
identical code (same http/retryable/hint) is idempotent and bumps a count, while a
code re-registered with a CONFLICTING definition fails closed. Registration is atomic
(validate every key, then mutate) so a malformed later key cannot leak earlier keys, and a
plugin's teardown decrements the count, deleting the code only when it reaches zero.
The dot (codes) vs underscore (tools) asymmetry is intentional: error codes live in the
envelope code field — a string the agent reads and branches on — where <plugin>.CODE
reads clearly and tells the agent which plugin failed; tool names live in the MCP tool-name
namespace where punctuation safety matters.
4. The loader is in-process, explicit, and fails closed
loadPlugins(plugins, { coreVersion }) validates each manifest (name format, reserved
namespace, version, tool-name shape), checks the core version, namespaces tools and codes,
runs setup, and returns the namespaced tools plus an idempotent teardownAll. Any failure
— bad manifest, version mismatch, duplicate namespace or tool name, or a throwing setup —
rejects the whole load and tears down any plugins already loaded in that call, so a
half-initialised set never reaches the dispatcher. createServer({ plugins }) is async for
this reason; close() runs each plugin's teardown (and unregisters its codes).
5. No auto-scan
The core NEVER discovers plugins by scanning node_modules. Plugins are passed explicitly
to createServer (or named explicitly on the CLI, a forthcoming ergonomic). v1 trusts
first-party in-process plugins; community-plugin sandboxing is out of scope and tracked
separately.
6. Core-version check (v1)
coreVersionRange is optional; v1 supports * (any) or an exact match against the running
core version, rejecting a mismatch with PLUGIN_VERSION_MISMATCH. Full semver-range matching
is a forthcoming follow-up, kept dependency-free for now.
Rationale
- Lean core protects agent accuracy and install size — the two concrete costs above.
- Namespacing prevents collisions without a central allocator — two plugins can both
ship a
starttool or aFAILEDcode; the namespace disambiguates. - Fail-closed loading mirrors the eval opt-in and the operation-type validation: a misconfigured extension fails at boot, never silently at an agent call.
- Reusing
AnyToolDefinitionandOperationType(no separate plugin-tool shape) keeps one tool contract; ADR-007's ten principles apply to plugin tools unchanged.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Build every capability into the core | Bloats install + tool count; widens default security surface; couples release cadence. |
Auto-scan node_modules for plugins |
Implicit, surprising, and a supply-chain risk; explicit configuration is safer and clearer. |
Extend the closed ErrorCode union for plugin codes |
Impossible at runtime (keyof typeof ERROR_CODES); a parallel runtime registry is the only way to add codes after compile. |
<plugin>/<tool> or <plugin>:<tool> tool names |
/ and : break some MCP hosts; underscore is portable and consistent with electron_*. |
| Sandbox community plugins now | Out of scope; v1 trusts first-party in-process plugins. Sandboxing is tracked for later. |
Consequences
createServeris now async (a pluginsetupmay be async). The single production caller (the CLI) awaits it; tests that need plugins await it.ErrorResponse.codewidens fromErrorCodetoErrorCode | (string & {})to carry namespaced plugin codes while keeping core-code autocomplete. No exhaustive switch overcodeexists, so this is non-breaking.- The error-code mirror test stays core-only (it scans
packages/core/src); plugin codes are registered at runtime and live in plugin packages or test fixtures, outside its scan. - Downstream plugin tickets (trace, IPC, production) build on this contract; lifecycle
configand a CLI--pluginflag are forthcoming extensions, not part of this slice.
Status update (CLI loading + config, 2026-06-03)
The "forthcoming extensions" noted above are now realised — the contract above is unchanged; this records what was added on top of it:
importPlugin(spec)(plugins/resolve.ts) dynamic-imports a plugin by bare package specifier or file path (path.resolve+pathToFileURLfor paths; bare specifiers pass to Node resolution unchanged), reads the default or namedpluginexport, and throwsPLUGIN_LOAD_FAILED/PLUGIN_MANIFEST_INVALID. Dynamic import executes module top-level code, so the trust model is explicit-operator-supplied; community sandboxing stays out of scope.- CLI flags:
--plugin <name|path>(repeatable and comma-separated) and--plugin-config <name>={json}.parseCliArgsis now exported and unit-tested. - Config: an optional
configSchema(azodschema) on the plugin is validated against the supplied config (defaults applied) beforesetup(config)runs; a mismatch throws the newPLUGIN_CONFIG_INVALIDcore code. Config validation happens AFTER codes + loaded-metadata are recorded, so the fail-closed teardown unwinds both on a bad config. - Introspection:
electron_plugins(query) initially reports loaded{ name, version, tools }and is registered ONLY when at least one plugin loaded — a plugin-free server keeps the lean core surface. Its later, richer availability and config contract is recorded below; the dispatcher'stools/liststays authoritative. - Example:
examples/plugin-sampleis a plain-ESM plugin (one tool, one error code, a config schema, lifecycle hooks) plus a real-MCP scenario that loads it through the CLI--pluginflag.
Status update (per-server state and session cleanup, 2026-07-12)
The plugin contract is now at API 1.1.0. A plugin may expose createInstance(), which the loader
calls once for each server before manifest validation and setup. First-party plugins use this factory
path, so parsed config, capture registries, clocks, and active trace state live in per-server closures
instead of process-shared module state. Existing API 1.0 plugin objects remain valid: the factory is
optional and the loader uses the supplied object when it is absent.
setup(config, context?) gains an optional, additive server context. Its onSessionEnd callback
receives the released session id, reason (stop, force_kill, detach, or server_close), and the
remaining live ids. The server invokes listeners after removing the session; listener failures are
contained so a cleanup problem cannot turn a completed lifecycle operation into an MCP failure.
First-party capture and clock plugins use the callback to discard per-session entries. Trace recording
is server-scoped rather than session-scoped so it can include electron_stop and the later
trace_stop; it remains active until explicitly stopped or server teardown. Teardown unregisters every
listener, then clears the instance state as an idempotent final backstop.
Status update (plugin SDK subpath, 2026-07-12)
@electron-stagewright/core/plugin-sdk is the public, intentionally narrow authoring surface for
patterns proven across the first-party plugins: immutable parsed configuration and per-instance config
state, per-session lifecycle cleanup, a parameterized transport-capability guard, and the optional
sessionId schema fragment. It does not define generic plugin errors: plugins retain their own codes,
messages, and remediation hints because a shared capability shape does not imply a shared operator action.
The plugin contract remains exported from @electron-stagewright/core; the SDK is an additive subpath,
not a separate package or a replacement for StagewrightPlugin. Both surfaces follow the core package's
semantic-versioning policy: additions are minor-compatible, while a removal or incompatible signature
change requires a core major release. Plugins must import this documented subpath, never core/dist/*.
Status update (onboarding introspection, 2026-07-13)
The plugin contract is now at API 1.2.0. A plugin may add optional introspection metadata with
two declarative parts: requirements names eval targets and transport capabilities used by at least
one of its tools, while config.safeFields is an explicit top-level allowlist for parsed effective
configuration. This is explanatory metadata, not authorization: tool handlers retain their existing
runtime eval and transport-capability checks.
The loader validates that metadata before side effects, snapshots it on the loaded plugin, and exposes
only allowlisted config fields after schema defaults and parsing. It never returns undeclared config
values, so adding a credential field cannot silently disclose it through MCP. PLUGIN_CONFIG_INVALID
now carries field-addressable Zod issues and a correction path for --plugin-config or
pluginConfigs, while still unwinding registered codes on failure.
electron_plugins is assembled after plugin tool registration. Each reported tool therefore has an
actual enabled or disabled state; an eval-policy-hidden tool names the
eval_policy_disabled availability kind and the narrow eval target to grant. The response also lists registered
namespaced error codes, declared requirements, and safe effective config. It remains absent when no
plugin is loaded, preserving the lean-core invariant.
Status update (read-only server orientation, 2026-07-13)
The plugin contract is now at API 1.3.0. ToolContext.status is a deliberately read-only
orientation capability: it reports server uptime and the last stable error for a still-live session.
It exists so the core status tool can use the same handler contract as every other tool without
exposing the status tracker's mutation methods. It does not disclose logs, stacks, dialog content,
window arrays, or plugin-owned state, and it is not a plugin-status contributor API. Plugins that
need to surface their own operational state must continue to do so through their explicit tools or
a future dedicated, capability-limited contributor contract.
Status update (server failure orientation, 2026-07-28)
The plugin contract is now at API 1.4.0. ToolContext.status additively exposes optional readers
for the server instance start time and the most recent stable failed dispatch that cannot be
attributed to a currently live session. Keeping those readers optional preserves structural source
compatibility for plugin API 1.3 mocks and adapters; the core-owned reader always supplies both.
electron_status projects that record as server.last_error; failures against a live session
remain on that session for as long as it lives.
When a session ends, its last stable failure moves to the server breadcrumb instead of being discarded, carrying only the code and completion time. Without that promotion, a failure would survive or vanish depending on whether the session-end event beat the failing dispatch into the store — so the crash the agent most needs to see was the one most likely to be lost. A promoted record never displaces a newer server-level failure.
The breadcrumb contains only the registered tool name, stable error code, and completion timestamp. Unknown tool names are caller-controlled input, so their code is retained without reflecting the raw name. Messages, arguments, paths, session identifiers, details, stacks, and plugin configuration are never copied into status. A successful call does not erase the record; the last qualifying completion replaces it.
This state is intentionally process-local. server.started_at makes a replacement server instance
explicit, but a hard process crash cannot preserve its in-memory error record. Durable previous-exit
reporting would require a separate decision covering state location, multi-instance ownership,
atomic writes, cleanup, and privacy rather than silently adding host persistence to the plugin
contract.
Status update (first-party aliases and exact doctor preflight, 2026-07-27)
The CLI accepts each shipped first-party suffix as an explicit shorthand: for example,
--plugin production resolves to @electron-stagewright/plugin-production, while scoped
third-party package names and file paths pass through unchanged. This is package-name expansion,
not discovery: every plugin remains operator-selected, and import still executes trusted code in
the server process. Load failures preserve both the requested and resolved specifiers in
machine-readable details.
Standalone doctor now accepts the same configuration flags as serve mode (--plugin,
--plugin-config, --tool-profile, --operation-timeout-ms, --demo, eval policy, and path
controls). Both commands share one resolver. Doctor builds the unconnected server object graph,
therefore validating plugin import, manifest, config schema, setup, tool registration, and profile
composition, and immediately closes it so plugin teardown also runs. The server_config check is
blocking and never prints raw plugin configuration values. This exactness deliberately means that
doctor runs top-level/setup code from plugins the operator explicitly named; it does not weaken the
ADR-004 trust boundary or auto-load anything.
Related decisions
- ADR-006 (Error code registry) — anticipated
registerPluginCodesand<plugin>.CODE; this ADR realises that design. - ADR-008 (Server and tool dispatcher) — the tool-as-data contract and
createServer({ tools })that the plugin tools register through. - ADR-007 (Agent-native UX) — every plugin tool must follow all ten principles.
References
packages/core/src/plugins/— contract (types.ts), loader (loader.ts), resolver (resolve.ts), introspection tool (info-tool.ts), loaded registry (loaded-registry.ts).packages/core/src/errors/registry.ts—registerPluginErrorCodes/lookupErrorCodeDefinition,PLUGIN_CONFIG_INVALID.packages/core/src/errors/envelope.ts—makePluginError.packages/core/src/server/server.ts—createServer({ plugins, pluginConfigs }).packages/core/src/cli.ts—--plugin/--plugin-configflags,parseCliArgs.examples/plugin-sample/— a runnable authoring example loaded over the real MCP protocol.