@matatbread/matbot-plugin-api 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.7",
7
+ "version": "0.3.8",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/index.ts CHANGED
@@ -10,4 +10,5 @@ export * from './context-switch.js';
10
10
  export * from './hooks.js';
11
11
  export * from './plugin.js';
12
12
  export * from './broadcast.js';
13
+ export * from './notify.js';
13
14
  export * from './invoke-tool.js';
package/src/notify.ts ADDED
@@ -0,0 +1,239 @@
1
+ import type { Principal, Store } from './types.js';
2
+ import { createBroadcaster } from './broadcast.js';
3
+ import { tryCurrentPrincipal } from './principal-context.js';
4
+
5
+ /**
6
+ * What every notification carries, whoever produced it and however it arrived.
7
+ *
8
+ * Two independent axes ride here, and conflating them is the mistake this shape exists to prevent:
9
+ *
10
+ * - **attribution** — `instance`/`plugin`/`source`: who *produced* the fact. Drives per-producer
11
+ * filtering, and lets a bridge suppress its own echo.
12
+ * - **ownership** — `principal`: whose *data* the fact is about. This is the visibility axis, the
13
+ * input to `WatchVisibility.visible`. A notification with no principal is a global fact.
14
+ *
15
+ * A producer never sets `instance`: undefined means "this matbot". A bridge stamps it on ingress, so a
16
+ * forwarded event stays attributable to the server that produced it and never loops back out.
17
+ *
18
+ * `kind` is deliberately absent here: it is not something an arm declares but something its
19
+ * {@link Notifications} key *is*, grafted on by {@link Notification}. Declaring it on the arm as well
20
+ * would be the same string written twice with nothing keeping the two honest.
21
+ */
22
+ export interface NotificationBase {
23
+ /** Producing plugin's name; `core` is reserved for the host and core itself. Defaulted from the
24
+ * emitting plugin's scope, so an ordinary producer never sets it. It stays overridable rather than
25
+ * enforced because relaying another producer's event is a legitimate act — that is exactly what a
26
+ * bridge does with a notification it received from another instance. */
27
+ readonly plugin: string;
28
+ /** Producer-defined sub-channel within that plugin (`tools`, `save`, `job-output`, …). Opaque to core. */
29
+ readonly source: string;
30
+ /** Originating matbot instance. Undefined ⇒ produced locally. Set only by a bridge, on ingress. */
31
+ readonly instance?: string;
32
+ /** Whose data this is about — the visibility axis, NOT attribution. Undefined ⇒ a global fact. */
33
+ readonly principal?: Principal;
34
+ }
35
+
36
+ /**
37
+ * The thing addressed by `(namespace, id)` is no longer what you last read — re-read it.
38
+ *
39
+ * Deliberately named for that contract and not for a medium: this is published by `Store` writes
40
+ * (via {@link notifyingStore}), by `FileStore` writes, and by a share/unshare that passes through
41
+ * neither. **Use it whenever the change is an invalidation of an addressable item**, wherever the item
42
+ * lives — including a plugin watching something matbot doesn't own. Define a `kind` of your own only
43
+ * when you carry information a consumer *cannot* get by re-reading (progress, a measurement, an
44
+ * external event); squeezing that in here as `detail` is the mistake this note exists to prevent.
45
+ *
46
+ * Carries identity, never the value: an event is queued per subscriber and is stale the moment a
47
+ * concurrent writer lands, so a consumer that acts on a payload races the source it is mirroring.
48
+ *
49
+ * `namespace` is the ROUTING namespace (`files`, `skills`, `sessions`, a document namespace) — the axis
50
+ * a profile isolates and `visible` routes on — not a content sub-namespace. `detail` is advisory only:
51
+ * a cheap payload for an in-place UI update (a file's size) that a consumer may render but must never
52
+ * treat as authoritative.
53
+ */
54
+ export interface ItemChange extends NotificationBase {
55
+ readonly namespace: string;
56
+ readonly id: string;
57
+ readonly operation: 'saved' | 'deleted';
58
+ readonly detail?: unknown;
59
+ }
60
+
61
+ /** A process-global registry gained or lost a member (tools, plugins). No owner, no item identity. */
62
+ export interface RegistryChange extends NotificationBase {
63
+ readonly registry: string;
64
+ readonly name: string;
65
+ readonly operation: 'added' | 'removed';
66
+ }
67
+
68
+ /**
69
+ * The wire tags for the two kinds defined here. A `kind` is a globally-scoped token — unlike a type
70
+ * name, which an importer can rename out of a clash — so these exist to give one back: import the const
71
+ * under whatever local name suits (`import { ItemChangeKind as Changed }`) and use it as a `case` label
72
+ * or in a filter. `satisfies` ties each to a real key, so a typo fails the build here instead of
73
+ * becoming a predicate that silently never matches.
74
+ */
75
+ export const ItemChangeKind = '@matatbread/matbot-plugin-api#ItemChange' satisfies keyof Notifications;
76
+ export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange' satisfies keyof Notifications;
77
+
78
+ /**
79
+ * The registry of notification shapes, keyed by `kind` — augment it exactly like `MatbotServices` /
80
+ * `MarkerData` / `ToolContracts` to add your own:
81
+ *
82
+ * export interface JobProgress extends NotificationBase { done: number; total: number }
83
+ *
84
+ * declare module '@matatbread/matbot-plugin-api' {
85
+ * interface Notifications { '@fnarr/jobs#JobProgress': JobProgress }
86
+ * }
87
+ *
88
+ * **The key is `<package-name>#<InterfaceName>` — no translation, no invention.** As with the service
89
+ * registry, the string is the erasure-time stand-in for a type identity; unlike a type name, it is
90
+ * globally scoped and an importer cannot rename it out of a collision, so the package name (already
91
+ * unique, npm guarantees it) qualifies it. The prefix names the package that *defines* the shape, never
92
+ * the one emitting it — `plugin` is the emitter, and four different plugins emit `ItemChange`. The rule
93
+ * is enforced by {@link Qualified} at the `notify` call; export the key as a const beside the interface
94
+ * (see {@link ItemChangeKind}) so consumers get a renameable handle back.
95
+ *
96
+ * **The set is open at runtime.** A kind can arrive from a plugin that isn't in this build, or bridged
97
+ * from another server — so a `switch (n.kind)` over notifications must always have a `default`, and
98
+ * exhaustiveness checking is unsound here (unlike a closed discriminated union). A bridge that wants a
99
+ * foreign kind typed augments this itself; otherwise it validates at its own boundary, like any other
100
+ * system edge.
101
+ */
102
+ export interface Notifications {
103
+ '@matatbread/matbot-plugin-api#ItemChange': ItemChange;
104
+ '@matatbread/matbot-plugin-api#RegistryChange': RegistryChange;
105
+ }
106
+
107
+ /** A notification as a consumer sees it: the arm, plus the `kind` its registry key supplies. Grafting
108
+ * `kind` on here rather than declaring it per-arm is what makes "the tag equals the key" unrepresentable
109
+ * rather than a convention to police. */
110
+ export type Notification = {
111
+ [K in keyof Notifications]: Notifications[K] & { readonly kind: K };
112
+ }[keyof Notifications];
113
+
114
+ /** One arm as a standalone type, for the rare annotation that wants a single kind rather than the union. */
115
+ export type Notified<K extends keyof Notifications> = Notifications[K] & { readonly kind: K };
116
+
117
+ /** Fails an arm whose key isn't `<package>#<Name>`. Intersected into {@link NotifyInput}, so a malformed
118
+ * key surfaces at the `notify` call — in the compilation that declared the augmentation, since the
119
+ * mapped type is resolved at the use site. Nothing at runtime; see `createNotifier` for the producers
120
+ * TypeScript can't see. */
121
+ type Qualified<K> = K extends `${string}#${string}` ? unknown
122
+ : { 'ERROR — notification kind must be `<package-name>#<InterfaceName>`': K };
123
+
124
+ /** What a producer passes to {@link Notifier.notify}: any registered arm, plus its key as `kind`, minus
125
+ * the `plugin` the runtime stamps. Mapped over the registry (not `Omit<Notification, …>`) so it
126
+ * distributes across arms and picks up augmentations. `plugin` stays settable for the one caller that
127
+ * legitimately relays another's event — a bridge replaying a remote notification. */
128
+ export type NotifyInput = {
129
+ [K in keyof Notifications]: Qualified<K> & Omit<Notifications[K], 'plugin'> & { readonly kind: K; plugin?: string };
130
+ }[keyof Notifications];
131
+
132
+ /** Per-subscriber predicate. Applied at emit time, so a subscriber never queues what it didn't ask for. */
133
+ export type NotificationFilter = (notification: Notification) => boolean;
134
+
135
+ /**
136
+ * The notification bus: one fan-out for every "something changed" fact a frontend, cache, or bridge
137
+ * needs, replacing the per-kind bespoke streams each producer used to hand out.
138
+ *
139
+ * A swappable {@link MatbotServices} member, not fixed runtime plumbing. The host boots an in-process
140
+ * broadcaster; `register('Notifier', …)` swaps in a distributed one (Redis, NATS, a matbot-to-matbot
141
+ * bridge) behind the usual capture-safe proxy, and unloading that plugin reverts to the host's default.
142
+ * Matbot owns the envelope and the registration surface; it does not own delivery.
143
+ *
144
+ * **What matbot guarantees, and an alternative implementation must preserve:**
145
+ * - `notify` never throws and never blocks — enqueue and return; a slow subscriber blocks nobody.
146
+ * - Ordered per producer to each subscriber. No ordering across producers.
147
+ * - Local delivery is at-most-once, in-memory. **No persistence, no replay, no delivery guarantee.**
148
+ * A sink attaching mid-flight has missed whatever preceded it — so a sink re-queries on attach and
149
+ * treats notifications purely as invalidation. Never put current state in the bus.
150
+ * - Filters are applied before queueing.
151
+ * - An implementation that forwards off-box MUST stamp `instance` on ingress and MUST NOT forward a
152
+ * notification that already carries a foreign `instance` — that is the loop break.
153
+ */
154
+ export interface Notifier {
155
+ /** Publish. `plugin` is stamped from the calling plugin's scope unless explicitly relayed. */
156
+ notify(notification: NotifyInput): void;
157
+ subscribe(signal?: AbortSignal, filter?: NotificationFilter): AsyncIterable<Notification>;
158
+ /** Detached observation loop: awaits each handler before pulling the next, isolates a throwing
159
+ * handler, ends when `signal` aborts. The fire-and-forget form of the for-await every sink writes. */
160
+ consume(handler: (notification: Notification) => void | Promise<void>, signal?: AbortSignal, filter?: NotificationFilter): void;
161
+ }
162
+
163
+ /**
164
+ * The host's boot default: in-process fan-out over the shared broadcaster, one queue per subscriber.
165
+ * `defaultPlugin` is stamped on anything published without one — the host passes `core`, and the
166
+ * per-plugin scope built in setupPlugin passes the plugin's own name.
167
+ */
168
+ export function createNotifier(defaultPlugin: string): Notifier {
169
+ const events = createBroadcaster<Notification>();
170
+ return {
171
+ notify(notification) {
172
+ const plugin = notification.plugin ?? defaultPlugin;
173
+ // The type-level rule is erased by the time this runs, and TypeScript never saw the producers that
174
+ // matter most here: a plain-JS plugin, or a bridge injecting another instance's traffic. Warn, don't
175
+ // throw — `notify` is contractually non-throwing, and a badly-named kind is a hygiene fault, not a
176
+ // delivery failure. Only the shape is checkable; that the prefix names the *definer* is not, since
177
+ // the emitter legitimately differs from it.
178
+ if (!notification.kind.includes('#')) {
179
+ console.warn(`[matbot] ${plugin} published unqualified notification kind "${notification.kind}" — expected <package-name>#<InterfaceName>`);
180
+ }
181
+ events.emit({ ...notification, plugin } as Notification);
182
+ },
183
+ async *subscribe(signal?: AbortSignal, filter?: NotificationFilter) {
184
+ for await (const event of events.subscribe(signal, filter ? e => filter(e.value) : undefined)) yield event.value;
185
+ },
186
+ consume(handler, signal?, filter?) {
187
+ events.consume(e => handler(e.value), signal, filter ? e => filter(e.value) : undefined);
188
+ },
189
+ };
190
+ }
191
+
192
+ /** Re-scope a notifier so everything published through it is attributed to `plugin`. */
193
+ export function scopedNotifier(notifier: Notifier, plugin: string): Notifier {
194
+ return {
195
+ notify(notification) { notifier.notify({ ...notification, plugin: notification.plugin ?? plugin } as NotifyInput); },
196
+ subscribe: (signal, filter) => notifier.subscribe(signal, filter),
197
+ consume: (handler, signal, filter) => notifier.consume(handler, signal, filter),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Wrap a store so every successful write announces itself. For a namespace with ONE writer, an explicit
203
+ * `notify` at that writer is clearer; use this where a namespace has many — `sessions` is written by the
204
+ * turn pump (title derivation, persist-at-turn-start, the assistant commit), the `session_action` tool
205
+ * (rename/hide/unhide), `session_edit` (fork/split), and the frontend (a session minted over HTTP). One
206
+ * wrapper covers them all and cannot be forgotten by the next writer to arrive.
207
+ *
208
+ * It sits ABOVE the backend, so it is storage-independent (no backend implements anything) and profile-
209
+ * independent (the principal is read ambiently at write time, which is also what makes it the correct
210
+ * partition for visibility filtering). It observes writes made THROUGH it — a second process, or a
211
+ * writer that bypassed this store, is not seen; that is what a backend's own watch, where one exists,
212
+ * is for.
213
+ */
214
+ export function notifyingStore<T extends { id: string; version: string }>(
215
+ store: Store<T>, notifier: Notifier, namespace: string, source: string,
216
+ ): Store<T> {
217
+ const announce = (operation: 'saved' | 'deleted', id: string): void => {
218
+ const principal = tryCurrentPrincipal();
219
+ notifier.notify({
220
+ kind: ItemChangeKind, source, operation, namespace, id,
221
+ ...(principal !== undefined ? { principal } : {}),
222
+ });
223
+ };
224
+ return {
225
+ get: id => store.get(id),
226
+ query: q => store.query(q),
227
+ async set(id, value) { await store.set(id, value); announce('saved', id); },
228
+ async cas(id, expected, next) {
229
+ const res = await store.cas(id, expected, next);
230
+ if (res.ok) announce('saved', id);
231
+ return res;
232
+ },
233
+ async delete(id, expectedVersion) {
234
+ const deleted = await store.delete(id, expectedVersion);
235
+ if (deleted) announce('deleted', id);
236
+ return deleted;
237
+ },
238
+ };
239
+ }
package/src/plugin.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type {
2
- FileStore, StoreChange, Principal, Vault, Message, ModelParameters,
2
+ FileStore, Principal, Vault, Message, ModelParameters,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage,
5
5
  TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy,
6
6
  } from './types.js';
7
7
  import type { Routed } from './broadcast.js';
8
+ import type { Notifier } from './notify.js';
8
9
  import type { HookRegistry } from './hooks.js';
9
10
 
10
11
  export const PLUGIN_API_VERSION = '0.1';
@@ -114,6 +115,12 @@ export interface MatbotServices {
114
115
  * reference held across a swap keeps resolving to the live backend. Always present (boot default). */
115
116
  readonly Vault: Vault;
116
117
  readonly KnowledgeIndex: KnowledgeIndex;
118
+ /** The notification bus — every "something changed" fact, one fan-out. Also the `register('Notifier',
119
+ * impl)` swap key: the host boots an in-process broadcaster, a plugin may swap in a distributed one
120
+ * (and unloading it reverts to the boot default). Capture-safe behind a proxy. Always present.
121
+ * Within a plugin's `setup()` this is scoped, so published notifications carry that plugin's name.
122
+ * See {@link Notifier}. */
123
+ readonly Notifier: Notifier;
117
124
  /** Optional tool-presentation policy: chooses which tools are advertised to the model per provider
118
125
  * call (a large-library search/deferral plugin registers one). Absent ⇒ the runner advertises the
119
126
  * whole turn snapshot. A plain registered service (not a swap-member); consumed as a member. See
@@ -504,18 +511,13 @@ export interface StorageBackend {
504
511
  }
505
512
 
506
513
  /**
507
- * The partition-aware file-watch layer, registered by a storage backend that partitions files per
508
- * principal (the profiles backend). Consumed by a frontend firehose so it can (a) observe file events
509
- * across *every* partition, each stamped with the {@link Routed} origin that produced it, and (b) decide,
510
- * per SSE connection, whether a given event is visible to that connection's principal. Absent ⇒ no
511
- * partitioning: the frontend falls back to the plain single-stream `fileStore.watch()` with no filter.
512
- * The origin/visibility split is generic (files today; the same shape serves skills/stores later).
514
+ * The per-connection visibility layer, registered by a storage backend that partitions per principal (the
515
+ * profiles backend). Consumed by a frontend firehose to decide, per SSE connection, whether a given
516
+ * notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
517
+ * sees everything. Generic across kinds: it answers on (`namespace`, `id`, `origin`), so files, skills and
518
+ * stores all route through the one predicate.
513
519
  */
514
520
  export interface WatchVisibility {
515
- /** Every partition's file events, merged into one stream, each a self-describing {@link StoreChange}
516
- * (routing namespace `'files'`, the file id, and the {@link FileMetaData} in `detail`) tagged with the
517
- * origin partition — so the firehose filters and routes files through the same predicate as any stream. */
518
- watchFiles(signal?: AbortSignal): AsyncIterable<Routed<StoreChange>>;
519
521
  /**
520
522
  * The per-connection visibility predicate for ANY partitioned event stream (files, skills, …): would a
521
523
  * connection owned by `viewer` see the change to (`namespace`, `id`) produced by `origin`? True iff the
@@ -39,6 +39,18 @@ export interface StoreQuery {
39
39
  sort?: SortSpec[];
40
40
  limit?: number;
41
41
  cursor?: string | undefined;
42
+ /**
43
+ * The caller's promise not to mutate the returned documents, freeing a backend to hand back shared
44
+ * instances instead of freshly-materialised ones. A pure optimisation hint: a backend may ignore it
45
+ * and nothing changes, so it is never load-bearing for correctness — but a backend that honours it
46
+ * can skip re-materialising documents that have not changed since the last query, which for a
47
+ * summary listing (read every document, use four fields) is the entire cost of the call.
48
+ *
49
+ * Set it only where the promise is actually kept. A read-modify-write path — pull a page, edit a
50
+ * document, `cas` it back — must NOT set it: the instance it edits may be one another caller is
51
+ * still reading, and a backend caching it would then be caching an edit that never reached disk.
52
+ */
53
+ immutable?: boolean;
42
54
  }
43
55
 
44
56
  export interface QueryResult<T> {
package/src/types.ts CHANGED
@@ -730,6 +730,25 @@ export interface ToolContext {
730
730
  unloadPlugin(specifier: string): Promise<boolean>;
731
731
  }
732
732
 
733
+ /**
734
+ * The read-only facts about the call a composed function is running under, injected into a
735
+ * `tool_function` body as the ambient `context` binding beside `tool`/`toolInContext`. Deliberately
736
+ * narrower than {@link ToolContext}: the identity of the call, not its capabilities — `vault`,
737
+ * `files`, `prompt` and plugin (un)loading stay off a surface authored by a model and reachable only
738
+ * through the tool contracts. A tool with real source takes `ToolContext` and needs none of this.
739
+ *
740
+ * Not `ToolCallContext` — that name belongs to the `toolcall` hook's context; this is the composed
741
+ * caller's own view, not a hook's view of a call about to run.
742
+ */
743
+ export interface ComposedCallContext {
744
+ readonly callId: string;
745
+ readonly sessionId: string;
746
+ /** The provider key driving the turn — the same one nested `tool.x(…)` calls inherit. */
747
+ readonly provider?: string;
748
+ readonly workdir?: string;
749
+ readonly signal: AbortSignal;
750
+ }
751
+
733
752
  /**
734
753
  * A tool's runtime. `R` is the type of the `value` carried by its `result` event — declared once,
735
754
  * at the source, so the executor's yields and the tool's {@link ToolContracts} registry entry can't
@@ -780,28 +799,6 @@ export interface FileHandle extends FileMetaData {
780
799
  stream(signal?: AbortSignal): AsyncIterable<Uint8Array>;
781
800
  }
782
801
 
783
- export type FileEvent = FileMetaData & { changed: Array<keyof FileMetaData> };
784
-
785
- /**
786
- * The generic, self-describing change envelope every partitioned CRUD stream emits (files, skills, and
787
- * future partitioned stores) — the payload half of a {@link Routed} event, whose `origin` carries the
788
- * acting principal/partition. It supersedes the per-stream bespoke shapes (the old `SkillEvent`, the raw
789
- * `FileEvent` on the wire) so a frontend firehose can filter and route every stream through ONE predicate.
790
- *
791
- * `namespace` is the ROUTING namespace the change lives under — `'files'`, `'skills'`, or a document
792
- * namespace — NOT a content sub-namespace (a file's own `metadata.namespace`, e.g. `'workspace'`, rides in
793
- * `detail`). Pairing `namespace` + `id` is exactly what `WatchVisibility.visible` needs to decide, per
794
- * connection, whether the viewer routes that item to the same partition (or has it shared in). `detail`
795
- * carries an optional rich payload for in-place UI updates — a file change ships its {@link FileMetaData}
796
- * so a sidebar row can update its size without a re-fetch; a skill change needs none (a refresh ping).
797
- */
798
- export interface StoreChange {
799
- readonly operation: 'saved' | 'deleted';
800
- readonly namespace: string;
801
- readonly id: string;
802
- readonly detail?: unknown;
803
- }
804
-
805
802
  export interface FileFilter {
806
803
  sessionId?: string;
807
804
  mimeType?: string;
@@ -823,8 +820,6 @@ export interface FileStore {
823
820
  delete(id: string): Promise<void>;
824
821
  list(filter?: FileFilter): AsyncIterable<FileHandle>;
825
822
  putTemp(name: string, mimeType: MimeType, data: AsyncIterable<Uint8Array>): Promise<FileHandle>;
826
- /** Observe file changes. Implementations that cannot watch their backing store omit this. */
827
- watch(signal?: AbortSignal): AsyncIterable<FileEvent>;
828
823
  }
829
824
 
830
825
  // ── Frontend ──────────────────────────────────────────────────────────────────
@@ -886,19 +881,16 @@ export type HealthStatus =
886
881
 
887
882
  // ── Registries ────────────────────────────────────────────────────────────────
888
883
 
889
- export type ToolRegistryEvent =
890
- | { type: 'registered'; name: string; pluginName?: string }
891
- | { type: 'removed'; name: string };
892
-
884
+ /** Tool CRUD is observed on the {@link Notifier}, as a `RegistryChange` with `registry: 'tools'` — this
885
+ * registry had its own broadcaster over the same primitive, which is duplication, not layering. One
886
+ * notification per tool (removeByPlugin announces a `removed` per matched tool); the registering
887
+ * plugin's name rides in `detail`, advisory as ever — resolve the name for anything authoritative. */
893
888
  export interface ToolRegistry {
894
889
  register(tool: Tool): void;
895
890
  remove(name: string): void;
896
891
  resolve(name: string): Tool | null;
897
892
  list(): readonly Tool[];
898
893
  removeByPlugin(pluginName: string): void;
899
- /** Observe tool CRUD as it happens. Read-only — observers cannot veto a registration. One event
900
- * per tool (removeByPlugin emits a `removed` per matched tool). The stream ends when `signal` aborts. */
901
- watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent>;
902
894
  }
903
895
 
904
896
  /** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
@@ -948,10 +940,6 @@ export interface ProviderRegistry extends ReadonlyMap<string, ProviderConfig> {
948
940
  revert(name: string): void;
949
941
  }
950
942
 
951
- export type PluginRegistryEvent =
952
- | { type: 'loaded'; name: string }
953
- | { type: 'unloaded'; name: string };
954
-
955
943
  // ── Pipeline events ─────────────────────────────────────────────────────────────
956
944
 
957
945
  /**