Electron Stagewrightdocs

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:

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.

References