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.
Type hierarchy
Section titled “Type hierarchy”Plugin Kit has five main type families.
| Family | Root type | What it is |
|---|---|---|
| Plugins | Plugin | Lifecycle-aware feature units. Split into GlobalPlugin<G> and SessionPlugin<S>. |
| Contexts | PluginContext | Bundle of registry + bus + extras handed to plugins at attach. GlobalPluginContext and SessionPluginContext add scope-specific fields. |
| Services | PluginService | Registry-resident services with injected settings. StatefulPluginService<Ctx> adds session lifecycle and subscription tracking. |
| Registry wrappers | RegistrationWrapper<T> | How the registry stores registrations. Three concrete subclasses: FactoryWrapper, LazySingletonWrapper, SingletonWrapper. |
| Capabilities | Capability | Metadata tags attached to wrappers, not to instances. |
Plus two orchestration types.
| Type | Role |
|---|---|
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. |
Registration, attachment, disposal
Section titled “Registration, attachment, disposal”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:
- The runtime calls
plugin.register(registry.scopedFor(pluginId)). - The scoped registry stamps
pluginIdonto every registration automatically. - The plugin contributes services. No event subscriptions yet.
Build phase
After all registrations are in, the runtime:
- Applies wildcard and per-plugin overrides from
RuntimeSettings. - Builds the scope context (global or session).
Attach phase
For each enabled plugin in the scope, the runtime runs an internal _runAttach(context) step:
- Iterate the plugin’s owned
StatefulPluginServices, bind each service’s context, and call itsattach()hook. - 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:
- Call the user’s
plugin.detach(context)hook. - For each owned
StatefulPluginService: calldetach(), then unbind the service’s context (cancels its tracked subscriptions). - Cancel any subscriptions the plugin registered via
PluginHelper.on(context, ...)/onRequest(context, ...)for this context. - 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 ─► goneIf you remember nothing else: register collects, the build phase resolves the world, attach activates, detach unwinds. Everything else is timing.
Priority resolution
Section titled “Priority resolution”The registry stores registrations in a sorted list keyed by serviceId. The sort is descending by priority, with defaultPriority = Priority.normal (500).
| Operation | Behavior |
|---|---|
| Insert | Registrations slot into the list by priority. For equal priorities, no extra tiebreaker is defined. |
| Resolve | Return the first entry (highest priority wins). |
| Resolve after | Walk 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 flow
Section titled “Resolve flow” 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 TresolveAfter 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.
Event dispatch
Section titled “Event dispatch”EventBus.emit<T>(event: ...) does roughly this:
- Build an
EventEnvelope<T>around the payload. - Notify every
bindobserver. - Merge general handlers with identifier-scoped handlers (if an identifier was provided). Sort descending by priority (higher first).
- For each handler, await its execution. Handlers can mutate the payload or call
envelope.stop(...)to halt propagation. - 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.
Dispatch flow
Section titled “Dispatch flow” 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-circuitedCross-scope event routing
Section titled “Cross-scope event routing”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
SessionBroadcastextension onGlobalPluginContext.sessionsofferssessions.emit<T>(event). It iterates every activePluginSessionand 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 reach | From a global plugin | From a session plugin |
|---|---|---|
| Global handlers | context.bus.emit(...) | context.globalBus.emit(...) |
| Same-session handlers | (not applicable) | context.bus.emit(...) |
| Every active session | context.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.
Session isolation
Section titled “Session isolation”Each PluginSession is genuinely isolated.
- Its
ServiceRegistryis a separate instance. - Its
EventBusis a separate instance. - Its
SessionPluginContextis 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.
Reconciliation
Section titled “Reconciliation”Global and session reconciliation run the same lifecycle on a toggle.
- Disable →
plugin.detach(context), then unregister the plugin’s services. - Enable →
plugin.register(scopedRegistry), thenplugin.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.