# Plugins & Lifecycle

This page covers the types you reach for when defining plugins, hosting them, or driving lifecycle transitions: `Plugin`, its two scoped subclasses, the contexts they receive, the `PluginRuntime` that owns them, and the exception thrown when a phase aggregates failures. For service registration mechanics see [Service Registry & Capabilities](https://plugin-kit.saad-ardati.dev/reference/service-registry-and-capabilities/); for event dispatch see [Event Bus & Events](https://plugin-kit.saad-ardati.dev/reference/event-bus-and-events/).

## `PluginId`

A zero-cost extension type wrapping a `String` that `implements String`. `PluginId` is the canonical identifier the runtime uses to register plugins, key service overrides, and resolve dependencies. It compiles to its underlying `String`, so `==` against a `String` literal works, it flows directly into any `String`-typed parameter or interpolation, and it can be used as a `Map` key with the same hash semantics as the wrapped value.

```dart
const id = PluginId('greeter');

id;                // 'greeter'
id == 'greeter';   // true (delegates to String equality)
```

Use `const PluginId('...')` everywhere a plugin id is required. The runtime accepts no other shape.

## `Plugin`

`Plugin` is the abstract base class. You never extend it directly: extend `GlobalPlugin<G>` or `SessionPlugin<S>` instead. Both inherit the same surface, listed here once.

```dart
abstract class Plugin {
  PluginId get pluginId;
  Set<PluginId> get dependencies => const {};
  List<FeatureFlag> get featureFlags => const [];

  void register(ScopedServiceRegistry registry) {}

  void attach(covariant PluginContext context) {}

  Future<void> detach(covariant PluginContext context) async {}

  Future<void> onPluginSettingsChanged(
    covariant PluginContext oldContext,
    covariant PluginContext newContext,
  ) async {}
}
```

| Member | Purpose |
|---|---|
| `pluginId` | Unique identifier. Lowercase snake_case by convention. Two plugins with the same id cannot coexist in one runtime. |
| `dependencies` | Set of `PluginId`s this plugin requires. The runtime auto-disables a plugin whose dependency is not enabled. Locked plugins with unmet dependencies stay enabled and log at severe. |
| `featureFlags` | Behavioral flags. Used by the runtime to set default enablement and by UI to render badges. |
| `register` | Contributes services to the registry. Called once per scope on every enabled plugin before any `attach` runs. |
| `attach` | Called after every plugin in the scope has registered. Subscribe to events here, not in `register`. Pure user hook with a no-op base implementation; do not call `super.attach`. The runtime attaches owned `StatefulPluginService`s before your hook runs, so the plugin's `attach(context)` can resolve and use its own services. |
| `detach` | Mirror of attach. Pure user hook with a no-op base implementation; do not call `super.detach`. After your hook returns, the runtime detaches owned services and cancels every subscription and binding registered via `PluginHelper.on(context, ...)` / `bind(context, ...)`. |
| `onPluginSettingsChanged` | Called for each plugin that ends a settings update enabled, including newly enabled plugins. Newly enabled plugins still run full register/attach, and newly disabled plugins detach. |

Equality is on `(runtimeType, pluginId)`. A `GlobalPlugin` and a `SessionPlugin` with the same `pluginId` are distinct instances, but runtime registration still rejects the duplicate `pluginId`.

## `StatefulPluginService<PKC>`

`StatefulPluginService` extends `PluginService` and adds lifecycle hooks plus a bound context window. Use it when a service needs `attach` / `detach`, event subscriptions, or direct access to scoped context fields while attached.

```dart
extractRegion(pluginServicesSource, 'stateful-plugin-service-basic')
```

| Member | Purpose |
|---|---|
| `activeSubscriptions` | Tracked subscriptions created by service helpers. The framework cancels them automatically after `detach` returns. |
| `hasContext` | Whether a context is currently bound (`true` between attach and detach). |
| `context` | The current bound context. Throws `StateError` if accessed outside the attach/detach window. |
| `attach` | Setup hook for subscription and startup logic while the context is bound. |
| `detach` | Cleanup hook that runs while context is still bound; framework cleanup runs after it returns. |

## `GlobalPlugin<G>` and `SessionPlugin<S>`

The two scoped subclasses. They differ only in which context they accept and when the runtime instantiates the scope. Pick `GlobalPlugin` for app-lifetime concerns, `SessionPlugin` for per-session concerns. The choice is architectural and is covered in [Plugins](https://plugin-kit.saad-ardati.dev/concepts/plugins/).

```dart
extractRegion(pluginLifecycleSource, 'session-plugin-basic')
```

The type parameter narrows `attach` and `detach` so the compiler rejects passing the wrong scope's context. If your runtime uses a custom `GlobalPluginContext` or `SessionPluginContext` subtype, parameterize the plugin on that subtype and the lifecycle hooks pick it up. See [Adding a Plugin](https://plugin-kit.saad-ardati.dev/guides/adding-a-plugin/) for a worked example.

## `FeatureFlag`

A zero-cost extension type wrapping a `String`. Defined flags steer runtime behavior; custom flags are inert tags you can use for UI or audit purposes.

```dart
extractRegion(coreSource, 'core-feature-flag')
```

| Flag | Effect |
|---|---|
| `FeatureFlag.locked` | Plugin is always enabled. Cannot be disabled via `RuntimeSettings`. Unmet dependencies log at severe instead of auto-disabling. |
| `FeatureFlag.experimental` | Plugin is disabled by default. Must be explicitly enabled via `RuntimeSettings`. |
| Custom (e.g. `FeatureFlag('requires_network')`) | Inspected by your code or UI. The runtime ignores unknown flags. |

```dart
extractRegion(pluginLifecycleSource, 'plugin-multiple-feature-flags')
```

Because `FeatureFlag` is a `const` extension type, the static constants compose naturally with Dart's dot-shorthand syntax inside a typed list literal.

## `PluginContext`

The bundle of services and helpers handed to every plugin lifecycle hook. Domain-specific projects subclass this to carry additional state.

```dart
extractRegion(customContextSource, 'plugin-context-stub')
```

To resolve a namespaced slot, build the `ServiceId` via `Namespace.call(...)` (or `Namespace.service(...)`) and pass it to `resolve(...)` or `maybeResolve(...)`. There are no separate `*Namespace` helpers; the registry only knows about `ServiceId`.

The shorthand methods on `PluginContext` delegate to `registry` and `bus`. Use them or reach through to the underlying instance directly: both produce identical results.

`PluginContext.stub()` returns a context wired to an empty registry and a fresh bus. Use it from unit tests when you want to instantiate a `PluginService` or call a lifecycle hook without standing up a runtime.

## `GlobalPluginContext`

`PluginContext` plus a list of every active `PluginSession`. Global plugins use this to broadcast across sessions or to look up which session has a particular plugin enabled.

```dart
extractRegion(customContextSource, 'global-plugin-context-stub')
```

To broadcast an event to every session, use the `SessionBroadcast` extension on `List<PluginSession>`. It iterates each session and emits on its bus, which is the explicit cross-scope path documented under [Runtime](https://plugin-kit.saad-ardati.dev/concepts/runtime/).

```dart
await context.sessions.emit(InvalidateCacheEvent());
```

`sessionOf` throws `StateError` if no session has the plugin enabled. Wrap the call when you cannot guarantee an enabled session exists.

## `SessionPluginContext`

`PluginContext` plus a reference to the global `EventBus`. Session plugins use it when they need to reach the global scope.

```dart
extractRegion(customContextSource, 'session-plugin-context-stub')
```

Emit on `context.bus` for in-session communication, on `context.globalBus` to reach handlers on the global bus. There is no implicit forwarding between the two.

## `PluginSession<K>`

A scoped session with its own registry, bus, and plugin attachments. The runtime constructs and tracks `PluginSession` instances; you receive them from `PluginRuntime.createSession` and dispose them when the session ends.

```dart
extractRegion(pluginLifecycleSource, 'session-plugin-attach')
```

| Member | Behavior |
|---|---|
| `registry` | Session-scoped registry seeded with overrides from `settings` and populated by every enabled session plugin's `register`. |
| `bus` | Session-scoped event bus. Disposed when the session is disposed. |
| `context` | The domain-specific context handed to lifecycle hooks. Type `K` is whatever your runtime parameterizes on. |
| `settings` | The `RuntimeSettings` snapshot used to create the session. Read-only; settings updates flow through the runtime's reconciliation path. |
| `isPluginEnabled` | Whether the plugin is currently active in this session. At session scope the dependency cascade has already resolved by the time the session exists, so the "enabled" set IS the "actually attached" set. Tracked separately from registry contents because some plugins only register tools. |
| `enabledPluginIds` | The unmodifiable set behind `isPluginEnabled`. Use for "which plugins is this session running?" |
| `dispose` | Detaches every enabled plugin, disposes the session bus, removes the session from the runtime. Throws `PluginLifecycleException` aggregating any detach failures. |
| `resolve` / `emit` / `on` | Convenience helpers via `SessionHelper`. Equivalent to going through `registry` or `bus` directly. |

## `PluginRuntime<G, S>`

The lifecycle engine. Owns the global scope, creates sessions, runs reconciliation. Generic over the global and session context types: `G extends GlobalPluginContext` and `S extends SessionPluginContext`. Use the defaults when you do not need custom fields; otherwise supply `globalContextFactory` to `init` and `contextFactory` to `createSession` so the runtime can construct your subtype.

```dart
class PluginRuntime<G extends GlobalPluginContext, S extends SessionPluginContext> {
  PluginRuntime({List<Plugin>? plugins});

  late final ServiceRegistry globalRegistry;
  late final EventBus globalBus;
  late final G globalContext;

  List<Plugin> get plugins;
  List<GlobalPlugin> get globalPlugins;
  List<SessionPlugin> get sessionPlugins;
  List<PluginSession<S>> get sessions;

  RuntimeSettings get settings;
  Stream<RuntimeSettings> get settingsStream;

  void addPlugin(Plugin plugin);
  void addPlugins(List<Plugin> plugins);

  PluginRuntime init({
    RuntimeSettings? settings,
    GlobalContextFactory<G, S>? globalContextFactory,
    UnknownReferencePolicy unknownReferencePolicy =
        UnknownReferencePolicy.throwError,
  });

  Future<PluginSession<S>> createSession({
    RuntimeSettings? settings,
    SessionContextFactory<G, S>? contextFactory,
  });

  Iterable<Plugin> get enabledPlugins;
  Set<PluginId> get enabledPluginIds;
  List<Plugin> get attachedPlugins;
  Set<PluginId> get attachedPluginIds;
  bool isPluginEnabled(PluginId pluginId, [RuntimeSettings? settings]);
  bool isPluginAttached(PluginId pluginId);

  Future<void> updateSettings(RuntimeSettings newSettings);
  void updateSettingsSnapshot(RuntimeSettings value);
  void resetSettings();

  Future<void> updateGlobalSettings({
    required RuntimeSettings oldSettings,
    required RuntimeSettings newSettings,
  });

  Future<void> updateSessionSettings(
    PluginSession<S> session, {
    required RuntimeSettings newSettings,
  });

  Future<void> dispose();
}
```

Order of operations:

1. Instantiate the runtime with an optional plugin list. Add more later with `addPlugin` or `addPlugins`.
2. Call `init` once. The runtime registers and attaches every enabled `GlobalPlugin`, builds `globalRegistry`, `globalBus`, and `globalContext`.
3. Call `createSession` per session you need. Each call builds an isolated session registry and bus, registers every enabled `SessionPlugin`, and attaches them.
4. Call `updateGlobalSettings` or `updateSessionSettings` to reconcile a settings change. The runtime diffs old against new, runs detach for newly disabled plugins, register/attach for newly enabled, and `onPluginSettingsChanged` for survivors.
5. Call `dispose` once at shutdown. The runtime detaches global plugins, disposes every session, and disposes the global bus.

Plugin enablement falls back through three rules: `FeatureFlag.locked` plugins are always on, then an explicit `RuntimeSettings.plugins` entry wins, then `FeatureFlag.experimental` plugins default off while all other plugins default on. Use explicit `RuntimeSettings.plugins` entries to disable stable plugins or enable experimental ones, then pass settings to `init` (or to `updateSettings` later). Locked plugins stay enabled regardless of explicit settings.

`globalContextFactory` is required when `G` is a custom subtype of `GlobalPluginContext`. The default factory builds a base `GlobalPluginContext`, which cannot be cast to a tighter type. Same rule for `contextFactory` on `createSession` when `S` is custom.

`unknownReferencePolicy` controls how the runtime responds when `RuntimeSettings` references a plugin or service id this runtime does not know about (typo, renamed id, or cached settings written by a prior app version). Defaults to `UnknownReferencePolicy.throwError`: the base package stays strict so drift is loud during development and CI. Production load paths that read cached settings should pass `UnknownReferencePolicy.logAndSkip` so renamed ids do not crash startup; `UnknownReferencePolicy.ignore` silently drops the unknown entry. The policy is set once on `init` and is reused by every `createSession` / `updateSessionSettings` / `updateGlobalSettings` call on the same runtime. Three validation passes run at each entry point: plugin ids in `RuntimeSettings.plugins`, plugin ids in service pin keys, and (after register-all) service ids in plugin-scoped pins. Wildcard pins are exempt from the service-id pass.

`isPluginEnabled` answers "would this plugin be enabled under these settings?" without applying dependency resolution. Use it for previews and UI tooling.

When attach throws inside `init`, `createSession`, or a settings update, the runtime collects every failure and throws `PluginLifecycleException` after the loop completes. Plugins that did not throw remain attached.

### Settings storage and reconciliation

| Member | Behavior |
|---|---|
| `settings` | The current `RuntimeSettings` snapshot. Updated by `init` (when given a non-null `settings:`), `updateSettings`, `updateSettingsSnapshot`, and `resetSettings`. |
| `settingsStream` | Broadcast stream that emits whenever `settings` changes. New subscribers do not receive the current value; read `settings` for the latest snapshot. |
| `enabledPlugins` / `enabledPluginIds` | Settings-intent: plugins the current snapshot says should be on. Use for settings UI. |
| `attachedPlugins` / `attachedPluginIds` | Runtime-effective: plugins the runtime actually attached after dependency cascade. Use for "is it actually running." |
| `isPluginEnabled` | Pass an explicit `RuntimeSettings` to preview, omit it to query the current snapshot. |
| `updateSettings` | Full reconciliation: runs `updateGlobalSettings`, then `updateSessionSettings` for every active session sequentially in insertion order, then publishes the new snapshot. A failure in any phase aborts and preserves the prior snapshot. |
| `updateSettingsSnapshot` | Stores and publishes the new snapshot without any lifecycle work. Use when listeners need to see the change but the runtime has already converged (or you intend to converge it later). |
| `resetSettings` | Replaces stored settings with `RuntimeSettings()`. No reconciliation. |

```dart
extractRegion(pluginLifecycleSource, 'runtime-update-snapshot')
```

The two update modes solve different problems. `updateSettings` is the normal path: it converges the live runtime on a new state. `updateSettingsSnapshot` is for cases where listeners need to see a snapshot but the runtime already reflects it (replaying a saved draft into the UI, for example).

## Plugin lifecycle phases

Within a scope, every enabled plugin finishes each phase before any plugin starts the next. The full sequence on a fresh scope is `register` → `attach`. The full sequence on shutdown is `detach`.

| Phase | When | What runs |
|---|---|---|
| `register` | During `runtime.init` (global) or `runtime.createSession` (session) | `plugin.register(scopedRegistry)` populates the registry. No bus access yet. |
| `attach` | After every plugin in the scope has registered | The runtime attaches owned `StatefulPluginService`s, then calls `plugin.attach(context)`. Plugins subscribe to events here. |
| `onPluginSettingsChanged` | During `updateGlobalSettings` or `updateSessionSettings` for plugins enabled both before and after | Receives `oldContext` and `newContext` so the plugin can diff and react. |
| `detach` | During `dispose`, or during a settings update for a plugin transitioning enabled to disabled | The runtime calls `plugin.detach(context)`, detaches owned services, cancels tracked subscriptions, and clears the bound context. |

The practical rule: anything that depends on another plugin belongs in `attach`. At `register` time, peers may not have registered yet.

## `PluginLifecycleException`

Thrown when one or more plugins fail during a lifecycle phase. The runtime continues processing remaining plugins after a throw, then throws this exception with every collected failure once the loop completes.

```dart
extractRegion(exceptionsSource, 'exceptions-plugin-lifecycle-exception')
```

| Field | Meaning |
|---|---|
| `phase` | Name of the phase that aggregated the failures. One of `attachGlobal`, `attachSession`, `detachGlobal`, `detachSession`, `updateSessionSettings`, `updateGlobalSettings`. |
| `failures` | Unmodifiable list of `(pluginId, error, stackTrace)` records, one per plugin that threw. |

```dart
extractRegion(loggingSource, 'logging-lifecycle-exception')
```

The runtime never swallows lifecycle failures. If you want best-effort disposal that does not surface errors, catch `PluginLifecycleException` at the call site and log; the runtime has already finished its work by the time it throws.

## Related reading

[Plugins](https://plugin-kit.saad-ardati.dev/concepts/plugins/)
  [Runtime](https://plugin-kit.saad-ardati.dev/concepts/runtime/)
  [Adding a Plugin](https://plugin-kit.saad-ardati.dev/guides/adding-a-plugin/)
  [Settings](https://plugin-kit.saad-ardati.dev/guides/settings/)