Electron Stagewright docs

ADR-004: Plugin model

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:

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 setuprejects 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

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

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:

References