# Event Bus & Events

This page is the lookup reference for the public event types in `plugin_kit`. It assumes you already understand the model from the concept pages and want to confirm a signature, a return type, or a default value.

The bus is exported from the package barrel via `src/event_bus.dart`, alongside the convenience extensions in `src/plugin/plugin.dart`.

## `EventBus`

```dart
extractRegion(eventBusSnippets, 'event-bus-standalone')
```

Construct with the zero-argument constructor when you need a standalone bus (tests, custom hosts). In application code you almost never instantiate one yourself: each `PluginRuntime` owns one global bus and each `PluginSession` owns its own session bus. See [Runtime](https://plugin-kit.saad-ardati.dev/concepts/runtime/) and [Sessions](https://plugin-kit.saad-ardati.dev/concepts/sessions/) for who creates which.

`dispose()` clears every event handler, request handler, and `bind` callback, then flips `isDisposed` to `true`. The runtime calls it when a session ends or when the runtime itself is shut down. Calling it yourself on a runtime-owned bus is almost always a bug.

## `EventEnvelope<T>`

Every handler receives an envelope, not a raw payload. `emit` and `emitInternal` also return one.

```dart
extractRegion(eventBusSnippets, 'event-bus-envelope-class')
```

- `event` is mutable. Mutating it in a handler is how you transform the payload for the rest of the cascade.
- `identifier` is whatever was passed to `emit` (or `request`). It is read-only after construction.
- `stop(value)` halts the cascade, sets `event` to `value`, and flips `stopped` to `true`. Subsequent handlers in the priority chain do not run.
- `stopped` is the boolean the emitter checks on the returned envelope to see whether someone short-circuited the chain.

The bus distinguishes user-emitted events (visible to `bind` callbacks) from internal-emitted events (skipped by `bind`) via the `internal` parameter on `emit` / `emitSync`, not via the envelope's runtime type. All emit paths construct a plain `EventEnvelope<T>`; nothing else to construct.

## `on` and `bind`

### `on<T>` and `onSync<T>`

```dart
EventSubscription on<T>(
  EventHandler<T> handler, {
  int priority = Priority.normal,
  String? identifier,
});

EventSubscription onSync<T>(
  SyncEventHandler<T> handler, {
  int priority = Priority.normal,
  String? identifier,
});
```

`EventHandler<T>` is `FutureOr<void> Function(EventEnvelope<T>)`. `SyncEventHandler<T>` is the strictly-synchronous variant, `void Function(EventEnvelope<T>)`. Use `onSync` when the handler will be invoked through `emitSync` and async would be a bug.

Default priority is `Priority.normal` (500). Higher runs first, matching the registry: in both subsystems, higher numbers mean more authority. A handler at `Priority.elevated` (1000) runs before one at `Priority.normal` (500).

The returned `EventSubscription` is a typed handle that supports only `cancel()`. It is intentionally NOT a `StreamSubscription` because the bus is not stream-backed and has no backpressure, data callbacks, or `asFuture` semantics. Distinct from `EventBinding`, which is the declarative descriptor consumed by the Flutter listener-lifecycle utilities.

### `bind`

```dart
void Function() bind(EventBindingCallback callback);
```

`EventBindingCallback` is `void Function(EventEnvelope<dynamic>)`. The callback fires for every non-internal event, before typed handlers run. It cannot stop the cascade. Use it for tracing, analytics, or debug logging.

The return value is the unbind function. Call it to remove the callback.

```dart
extractRegion(eventBusSnippets, 'event-bus-bind')
```

Internal events emitted via `emitInternal` skip `bind` callbacks entirely.

### `hasRequestHandler`

```dart
bool hasRequestHandler<Request, Response>({String? identifier});
```

Reports whether at least one handler is registered for the `(Request, Response)` type pair. With `identifier`, only identifier-scoped handlers count. Useful for capability probes (does *anyone* in this scope answer this question yet) without paying for an exception.

## `emit` and `emitInternal`

```dart
extractRegion(eventBusSource, 'event-bus-emit')
```

`emit` wraps `event` in an `EventEnvelope<T>`, runs `bind` callbacks (when not internal), then walks the priority-merged handler chain. The cascade stops when a handler calls `envelope.stop(value)`. The returned envelope carries the final state; check `stopped` if you need to know whether someone short-circuited.

`emitSync` has the same dispatch semantics but throws `StateError` if any handler returns a `Future`. Use it for lifecycle events where async would be a correctness bug.

`emitInternal` is a thin wrapper that calls `emit` with `internal: true`. The only doc-visible difference: `bind` callbacks do not see internal events. Most application code will never call it; it is for system-level signals you want typed handlers to react to without cluttering analytics taps.

The `internal` parameter on `emit` itself is rarely used directly; prefer `emitInternal` so the intent reads at the call site.

## Request/response

The same bus also does typed RPC. The dispatch model mirrors `emit`: general handlers are merged with identifier-scoped handlers into one descending-priority sequence (higher first). Each handler is invoked in turn. The first handler to return a non-null `Response` wins; returning `null` concedes to the next handler.

`RequestHandler<Request, Response>` always returns `Response?`, so handlers can concede with `null` even when `Response` is non-nullable. The cascade behavior applies in both cases.

### Async

```dart
EventSubscription onRequest<Request, Response>(
  RequestHandler<Request, Response> handler, {
  int priority = Priority.normal,
  String? identifier,
});

Future<Response> request<Request, Response>(
  Request request, {
  String? identifier,
});

Future<Response?> maybeRequest<Request, Response>(
  Request request, {
  String? identifier,
});
```

`RequestHandler<Request, Response>` is `FutureOr<Response?> Function(EventEnvelope<Request>)`. The handler reads `envelope.event` to get the request payload, returns a non-null `Response` to claim, returns `null` to concede (so the next handler in priority order can try), or throws to signal a real error (the chain stops and the original exception propagates).

Pick the right method at the call site. `maybeRequest` is the canonical method when concession is a valid outcome: it returns `null` for the no-answer case. `request` is the assertion variant; use it only when at least one handler is guaranteed to claim.

When the chain produces no answer, `request` throws one of two sealed subtypes of `NoRequestAnswerException`:

- `RequestNotWiredException`: no handler is registered for the type pair, or no handler matched the requested identifier (carries a `wasIdentifierMismatch` boolean to distinguish). Almost always a wiring bug.
- `AllConcededException`: every registered handler ran and returned `null` on a non-nullable `Response`. The message recommends switching to `maybeRequest`.

`maybeRequest` catches `NoRequestAnswerException` and converts it to `null`. Any *other* exception thrown by a handler propagates unchanged through both methods; `maybeRequest` does not swallow handler exceptions.

### Sync

```dart
EventSubscription onRequestSync<Request, Response>(
  SyncRequestHandler<Request, Response> handler, {
  int priority = Priority.normal,
  String? identifier,
});

Response requestSync<Request, Response>(
  Request request, {
  String? identifier,
});

Response? maybeRequestSync<Request, Response>(
  Request request, {
  String? identifier,
});
```

`SyncRequestHandler<Request, Response>` is `Response? Function(EventEnvelope<Request>)`. Same concession-by-`null` and throw-stops-chain rules as the async variant. Handlers registered via `onRequestSync` are stored in the same buckets as async ones; the sync wrapper exists to refuse `Future` returns at the call boundary.

`requestSync` throws `StateError` if any invoked handler returns a `Future`. Same no-answer throw conditions as `request`: `RequestNotWiredException` for missing handlers, `AllConcededException` for fully-conceding chains with non-nullable `Response`. `maybeRequestSync` catches `NoRequestAnswerException` and returns `null` on the no-answer case; any *other* exception thrown by a handler (including the `StateError` on a Future-returning sync handler) propagates, matching the async `maybeRequest` contract.

## Identifier scoping

Both `emit` and `request` accept an optional `String? identifier`. So do `on`, `onRequest`, and the sync variants. The dispatch rules:

| Emit identifier | Subscriber identifier | Handler runs? |
|---|---|---|
| `null` | `null` | yes |
| `null` | `'foo'` | no |
| `'foo'` | `null` | yes |
| `'foo'` | `'foo'` | yes |
| `'foo'` | `'bar'` | no |

When the emit identifier is non-null, general (`null`-identifier) handlers and handlers scoped to that exact identifier are merged into a single priority-ordered sequence. Handlers scoped to any other identifier never see the event.

The same table applies to request handlers.

Identifiers are how one bus multiplexes across logical targets (tools, agents, panels, departments) without forcing a separate bus per target. They have no implicit hierarchy: `foo` and `foo.bar` are unrelated strings as far as the bus is concerned.

## Helpers on `Plugin`, `StatefulPluginService`, and `PluginSession`

The bus is reachable directly on every context, but the convenience extensions exported through `src/plugin/plugin.dart` exist so plugin code never has to manage subscription fields by hand.

### `PluginHelper` extension on `Plugin`

```dart
extension PluginHelper on Plugin {
  EventSubscription on<E>(
    PluginContext context,
    EventHandler<E> handler, {
    int priority = Priority.normal,
    String? identifier,
  });
  EventSubscription onRequest<Request, Response>(
    PluginContext context,
    RequestHandler<Request, Response> handler, {
    int priority = Priority.normal,
    String? identifier,
  });
  EventSubscription onRequestSync<Request, Response>(
    PluginContext context,
    SyncRequestHandler<Request, Response> handler, {
    int priority = Priority.normal,
    String? identifier,
  });
  void Function() bind(PluginContext context, EventBindingCallback callback);
  Future<EventEnvelope<T>> emit<T>(
    PluginContext context,
    T event, {
    String? identifier,
  });
}
```

Every helper takes the [`PluginContext`](https://plugin-kit.saad-ardati.dev/reference/plugins-and-lifecycle/) as its first argument because the same plugin instance is shared across sessions; there is no safe "current context" to read from a field. Inside `Plugin.attach` / `Plugin.detach` the `context` parameter is right there; pass it along.

Each subscription helper registers on `context.bus` and bookkeeps the returned `EventSubscription` into a per-context bucket. The framework cancels each context's bucket when that context detaches, so concurrent sessions of the same plugin do not trample each other's teardown.

### `StatefulPluginService` bus methods

`StatefulPluginService` exposes the same shape via the `StatefulPluginServiceHelper` extension. The service holds its own `context` reference (bound by the framework before `attach()` runs), so these helpers do NOT take a `PluginContext` parameter:

```dart
EventSubscription on<E>(EventHandler<E> handler, {int priority = Priority.normal, String? identifier});
EventSubscription onRequest<Request, Response>(
  RequestHandler<Request, Response> handler, {
  int priority = Priority.normal,
  String? identifier,
});
EventSubscription onRequestSync<Request, Response>(
  SyncRequestHandler<Request, Response> handler, {
  int priority = Priority.normal,
  String? identifier,
});
void Function() bind(EventBindingCallback callback);
Future<EventEnvelope<T>> emit<T>(T event, {String? identifier});
```

Subscriptions are appended to `StatefulPluginService.activeSubscriptions` and cancelled on `detach`. `emit` throws `StateError` if called outside the attach/detach window.

The full service reference, including `attach`/`detach` ordering and `injectSettings`, lives on the [Service Registry & Capabilities](https://plugin-kit.saad-ardati.dev/reference/service-registry-and-capabilities/) page.

### `SessionHelper` extension on `PluginSession`

```dart
extension SessionHelper on PluginSession {
  Future<EventEnvelope<T>> emit<T>(T event, {String? identifier});
  Future<EventEnvelope<T>> emitInternal<T>(T event, {String? identifier});

  EventSubscription on<T>(
    EventHandler<T> handler, {
    int priority = Priority.normal,
    String? identifier,
  });
  EventSubscription onRequest<Request, Response>(
    RequestHandler<Request, Response> handler, {
    int priority = Priority.normal,
    String? identifier,
  });
  EventSubscription onRequestSync<Request, Response>(
    SyncRequestHandler<Request, Response> handler, {
    int priority = Priority.normal,
    String? identifier,
  });

  Future<Response> request<Request, Response>(Request request, {String? identifier});
  Future<Response?> maybeRequest<Request, Response>(Request request, {String? identifier});
  Response requestSync<Request, Response>(Request request, {String? identifier});
  Response? maybeRequestSync<Request, Response>(Request request, {String? identifier});
}
```

The session helpers do not auto-track subscriptions: a `PluginSession` is the lifetime that owns the bus, so when the session ends the bus is disposed and every handler goes with it. Use these from host code that is driving a session from the outside.

## Cross-scope routing

The runtime keeps the global bus and each session bus isolated. Cross-scope emission is always explicit:

| From | To | API |
|---|---|---|
| Global plugin | Global plugins | `context.bus.emit(...)` |
| Session plugin | Same-session plugins | `context.bus.emit(...)` |
| Session plugin | Global scope | `context.globalBus.emit(...)` |
| Global plugin | Every active session | `context.sessions.emit<T>(event)` |

`SessionPluginContext.globalBus` is the same `EventBus` instance owned by the runtime. Reading it is fine; disposing it is not.

`context.sessions.emit<T>(...)` is provided by the `SessionBroadcast` extension on `List<PluginSession>`:

```dart
extension SessionBroadcast on List<PluginSession> {
  Future<void> emit<T>(T event, {String? identifier});
}
```

Each session's cascade starts concurrently (`Future.wait` with `eagerError: false`); every session runs to completion regardless of whether a peer threw, and the first error encountered surfaces on the returned future once all sessions have settled. If you need to inspect each session's outcome independently, iterate the list and `try`/`catch` around `session.bus.emit` yourself.

The full cross-scope story, including why isolation defaults are intentional, is on [Sessions](https://plugin-kit.saad-ardati.dev/concepts/sessions/).

## Related reading

[Event Bus (concept)](https://plugin-kit.saad-ardati.dev/concepts/event-bus/)
  [Events (from inside plugins)](https://plugin-kit.saad-ardati.dev/concepts/events/)
  [Runtime](https://plugin-kit.saad-ardati.dev/concepts/runtime/)
  [Service Registry & Capabilities](https://plugin-kit.saad-ardati.dev/reference/service-registry-and-capabilities/)