Skip to content

Architecture

This page is for contributors and for people who want to know why Plugin Kit is shaped the way it is. If you just want to use the library, the concept pages are the better starting point.

Most of the internal machinery exists to hold three tensions at the same time:

  • more than one feature can compete for the same slot
  • more than one session can be alive without leaking state
  • plugins can coordinate without holding references to each other

If a type here looks a little more ceremonial than a plain DI container or a plain event bus would need, that is usually why.

Plugin Kit has five main type families.

FamilyRoot typeWhat it is
PluginsPluginLifecycle-aware feature units. Split into GlobalPlugin<G> and SessionPlugin<S>.
ContextsPluginContextBundle of registry + bus + extras handed to plugins at attach. GlobalPluginContext and SessionPluginContext add scope-specific fields.
ServicesPluginServiceRegistry-resident services with injected settings. StatefulPluginService<Ctx> adds session lifecycle and subscription tracking.
Registry wrappersRegistrationWrapper<T>How the registry stores registrations. Three concrete subclasses: FactoryWrapper, LazySingletonWrapper, SingletonWrapper.
CapabilitiesCapabilityMetadata tags attached to wrappers, not to instances.

Plus two orchestration types.

TypeRole
PluginRuntime<G, S>The lifecycle engine. Owns the global scope, stores current settings, streams updates, creates and reconciles sessions.
PluginSession<S>A single session’s enabled-plugin set, scoped registry, scoped bus, and scoped SessionPluginContext. Created and torn down by the runtime; observable via runtime.sessions.

Plugins progress through their lifecycle in a fixed order. Within a scope, every enabled plugin finishes each phase before any plugin starts the next.

Register phase

For each enabled plugin in the scope:

  1. The runtime calls plugin.register(registry.scopedFor(pluginId)).
  2. The scoped registry stamps pluginId onto every registration automatically.
  3. The plugin contributes services. No event subscriptions yet.

Build phase

After all registrations are in, the runtime:

  1. Applies wildcard and per-plugin overrides from RuntimeSettings.
  2. Builds the scope context (global or session).

Attach phase

For each enabled plugin in the scope, the runtime runs an internal _runAttach(context) step:

  1. Iterate the plugin’s owned StatefulPluginServices, bind each service’s context, and call its attach() hook.
  2. Call the user’s plugin.attach(context) hook.

Each hook runs in isolation: a failure is logged and captured, the remaining services and the plugin’s own attach still run, and captured failures surface as a PluginStepAggregateException once the phase completes. Because services are activated first, the plugin’s attach(context) can already resolve and use its own services, peer services, and the bus.

Detach phase

_runDetach(context) mirrors attach in reverse, but with broader cleanup:

  1. Call the user’s plugin.detach(context) hook.
  2. For each owned StatefulPluginService: call detach(), then unbind the service’s context (cancels its tracked subscriptions).
  3. Cancel any subscriptions the plugin registered via PluginHelper.on(context, ...) / onRequest(context, ...) for this context.
  4. Cancel any bindings the plugin registered via PluginHelper.bind(context, ...) for this context.

Re-subscribing or re-binding inside a cancel callback is flagged as a leak step failure rather than being silently dropped or re-cancelled.

The practical rule: “I need information from another plugin” belongs in attach, not register. At register time, peers may not have registered yet.

enabled running
─────► register ─► build ─► attach ─────► onPluginSettingsChanged
(re-read config; no
re-attach)
disabled
──────► detach ─► unregister ─► gone

If you remember nothing else: register collects, the build phase resolves the world, attach activates, detach unwinds. Everything else is timing.

The registry stores registrations in a sorted list keyed by serviceId. The sort is descending by priority, with defaultPriority = Priority.normal (500).

OperationBehavior
InsertRegistrations slot into the list by priority. For equal priorities, no extra tiebreaker is defined.
ResolveReturn the first entry (highest priority wins).
Resolve afterWalk past any registration from the caller’s pluginId, return the next one.

Plugin-scoped overrides from RuntimeSettings are applied at registration time. Wildcard overrides are applied after registration, once the winning provider is known.

resolve<T>(ServiceId('agent.model'))
┌──────────────────────────────────────┐
│ slot 'agent.model' (priority desc.) │
├──────────────────────────────────────┤
│ enterprise_chat | priority 120 ◄────┼── winner
│ chat | priority 100 │
│ legacy_anthropic| priority 50 │
└──────────────────────────────────────┘
build (or reuse cached) instance
inject latest settings if PluginService
return T

resolveAfter enters the same list at the entry following the caller’s pluginId and walks the tail. resolveRaw returns the wrapper without instantiating; useful for capability inspection.

EventBus.emit<T>(event: ...) does roughly this:

  1. Build an EventEnvelope<T> around the payload.
  2. Notify every bind observer.
  3. Merge general handlers with identifier-scoped handlers (if an identifier was provided). Sort descending by priority (higher first).
  4. For each handler, await its execution. Handlers can mutate the payload or call envelope.stop(...) to halt propagation.
  5. Return the envelope to the emitter.

Request dispatch is the same shape, except handlers return a value. The first handler whose return value is non-null wins, and the rest are skipped.

The bus does not swallow handler exceptions. If a handler throws, the caller sees the exception. That is a deliberate design choice: silent failure in event pipelines is significantly harder to debug than a visible crash.

emit<T>(event, identifier?)
EventEnvelope<T> built
bind callbacks (skipped if internal)
merged general + identifier handlers, descending priority
▼ for each handler:
observe → mutate → optionally stop()
envelope returned
.event = final (possibly mutated) payload
.stopped = whether the cascade short-circuited

The global bus and each session bus are separate EventBus instances with no implicit forwarding between them. All cross-scope communication is explicit.

  • Global → sessions: the SessionBroadcast extension on GlobalPluginContext.sessions offers sessions.emit<T>(event). It iterates every active PluginSession and emits on that session’s bus, so every subscribed session plugin receives the event. Handlers on the global bus are not invoked.
  • Session → global: a session plugin emits on context.globalBus. Only handlers on the global bus run.
  • Intra-scope: context.bus.emit(...) stays within the scope it was emitted from (global bus if called from a global plugin, session bus if from a session plugin).
To reachFrom a global pluginFrom a session plugin
Global handlerscontext.bus.emit(...)context.globalBus.emit(...)
Same-session handlers(not applicable)context.bus.emit(...)
Every active sessioncontext.sessions.emit<T>(...)(route via global, then broadcast)

An earlier version of the runtime auto-forwarded global events into every session via a bind callback, but that made cross-scope routing hard to audit and made sessions.emit redundant. The current design keeps the two scopes isolated and makes every cross-scope emit a deliberate call.

Each PluginSession is genuinely isolated.

  • Its ServiceRegistry is a separate instance.
  • Its EventBus is a separate instance.
  • Its SessionPluginContext is a separate instance.
  • Its enabled-plugin set is independent of other sessions.

Two sessions sharing the same global runtime can have different settings snapshots, different enabled plugins, and different winning implementations for the same slot. They share a global registry (for cross-session shared services) and a global bus (for explicit cross-scope emits), but their session registries and session buses are completely independent.

Global and session reconciliation run the same lifecycle on a toggle.

  • Disableplugin.detach(context), then unregister the plugin’s services.
  • Enableplugin.register(scopedRegistry), then plugin.attach(context).
  • Survivor → every still-enabled plugin receives onPluginSettingsChanged(oldContext, newContext).

Direct subscriptions from attach tear down on disable without additional plumbing. Plugin.attach and Plugin.detach are pure user hooks; the runtime drives owned-service attach/detach and tracked-subscription cleanup around them, so plugins do not call super.attach or super.detach. StatefulPluginService is still the right home for long-lived session state because its subscription tracking makes cleanup trivially correct, but it is not a workaround for an asymmetry. The two scopes behave the same.

An earlier version of the runtime ran session reconciliation at service granularity, skipping the plugin’s own attach and detach on toggle. That shipped with a subtle footgun: direct bus subscriptions from attach leaked on disable. The current design resolves both scopes symmetrically through the plugin’s own lifecycle methods.