-
Notifications
You must be signed in to change notification settings - Fork 165
Expand file tree
/
Copy pathplugin.ts
More file actions
584 lines (516 loc) · 23.4 KB
/
Copy pathplugin.ts
File metadata and controls
584 lines (516 loc) · 23.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
import { Effect, type Schema as EffectSchema } from "effect";
import type { Context, Layer } from "effect";
import type { HttpClient } from "effect/unstable/http";
import type { HttpApiGroup } from "effect/unstable/httpapi";
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
import type { StorageFailure } from "./fuma-runtime";
import type { PluginBlobStore } from "./blob";
import type { Connection, ConnectionRef, CreateConnectionInput } from "./connection";
import type {
AuthMethodDescriptor,
Integration,
IntegrationConfig,
IntegrationDisplayDescriptor,
RegisterIntegrationInput,
} from "./integration";
import type { ToolInvocationRow } from "./core-schema";
import type {
AuthTemplateSlug,
ConnectionName,
IntegrationSlug,
Owner,
ProviderKey,
Subject,
Tenant,
} from "./ids";
import type { IntegrationDetectionResult } from "./types";
import type {
ElicitationDeclinedError,
ElicitationHandler,
ElicitationRequest,
ElicitationResponse,
} from "./elicitation";
import type {
ConnectionNotFoundError,
CredentialProviderNotRegisteredError,
IntegrationNotFoundError,
IntegrationRemovalNotAllowedError,
InvalidConnectionInputError,
} from "./errors";
import type { OAuthService } from "./oauth-client";
import type { CredentialProvider, ProviderEntry } from "./provider";
import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
import type {
CreateToolPolicyInput,
RemoveToolPolicyInput,
ToolPolicy,
UpdateToolPolicyInput,
} from "./policies";
import type { Tool, ToolAnnotations, ToolDef } from "./tool";
// ---------------------------------------------------------------------------
// OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor
// acts as. `owner:"user"` writes require a subject; pure-org executors leave it
// null. Plugins rarely read this — core handles partitioning — but it's exposed
// for plugins that label or key their own state by owner.
// ---------------------------------------------------------------------------
export interface OwnerBinding {
readonly tenant: Tenant;
readonly subject: Subject | null;
}
// ---------------------------------------------------------------------------
// StorageDeps — backing passed to a plugin's `storage` factory. Plugins see
// host-owned storage facades only. The (tenant, owner, subject) partition is
// the host's concern; plugin storage is already owner-scoped under the hood.
// ---------------------------------------------------------------------------
export interface StorageDeps {
readonly owner: OwnerBinding;
readonly blobs: PluginBlobStore;
readonly pluginStorage: PluginStorageFacade;
}
// ---------------------------------------------------------------------------
// Elicit — suspends the fiber, calls the invoke-time elicitation handler,
// resumes with the user's response. Available on static tool handlers and
// dynamic `invokeTool` handlers.
// ---------------------------------------------------------------------------
export type Elicit = (
request: ElicitationRequest,
) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
// ---------------------------------------------------------------------------
// IntegrationRecord — the catalog row a plugin reads back (its own opaque
// `config` included). Returned by `ctx.core.integrations.get`.
// ---------------------------------------------------------------------------
export interface IntegrationRecord extends Integration {
readonly config: IntegrationConfig;
}
// ---------------------------------------------------------------------------
// PluginCtx — threaded into every extension method, static tool handler, and
// dynamic tool handler. The v2 fold: `core.sources` → `core.integrations`,
// `secrets`/`connections`/`credentialBindings` → `connections` (provider-
// resolved) + `providers`, `scopes` → `owner`.
// ---------------------------------------------------------------------------
export interface PluginCtx<TStore = unknown> {
readonly owner: OwnerBinding;
readonly storage: TStore;
readonly pluginStorage: PluginStorageFacade;
readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
readonly core: {
readonly integrations: {
/** Register / replace this plugin's integration in the catalog. */
readonly register: (input: RegisterIntegrationInput) => Effect.Effect<void, StorageFailure>;
readonly update: (
slug: IntegrationSlug,
patch: { readonly description?: string; readonly config?: IntegrationConfig },
) => Effect.Effect<void, StorageFailure>;
readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>;
readonly get: (
slug: IntegrationSlug,
) => Effect.Effect<IntegrationRecord | null, StorageFailure>;
readonly remove: (
slug: IntegrationSlug,
) => Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure>;
readonly detect: (
url: string,
) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>;
readonly configureSchemas: () => readonly IntegrationConfigureSchema[];
readonly presets: () => readonly IntegrationPresetCatalogEntry[];
};
readonly policies: {
readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;
readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;
readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;
readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect<void, StorageFailure>;
};
};
/** Saved credentials. A connection IS the credential; resolve its value
* (refreshing OAuth tokens as needed) via `resolveValue`. */
readonly connections: {
readonly create: (
input: CreateConnectionInput,
) => Effect.Effect<
Connection,
| IntegrationNotFoundError
| CredentialProviderNotRegisteredError
| InvalidConnectionInputError
| StorageFailure
>;
readonly list: (filter?: {
readonly integration?: IntegrationSlug;
readonly owner?: Owner;
}) => Effect.Effect<readonly Connection[], StorageFailure>;
readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>;
readonly remove: (
ref: ConnectionRef,
) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;
readonly refresh: (
ref: ConnectionRef,
) => Effect.Effect<
readonly Tool[],
ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure
>;
/** Resolve a connection's value through its provider (and OAuth refresh).
* null if the provider can't produce one. */
readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>;
};
/** Registered credential backends — for discovery (browse a backend's items). */
readonly providers: {
readonly list: () => Effect.Effect<readonly ProviderKey[]>;
readonly items: (
provider: ProviderKey,
) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;
};
/** Shared OAuth service. */
readonly oauth: OAuthService;
/** Run `effect` inside a FumaDB transaction (atomic across plugin storage +
* core integration/tool writes). */
readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>;
}
// ---------------------------------------------------------------------------
// Per-connection tool production (the v2 successor to v1's `sources.register`
// inside a plugin's addSpec). Called by the executor at connections.create /
// refresh / oauth.complete; the result is stamped with addresses and persisted.
// ---------------------------------------------------------------------------
export interface ResolveToolsInput<TStore = unknown> {
/** The catalog record (public projection) whose connection is being resolved. */
readonly integration: Integration;
/** The plugin's stored opaque config for that integration. */
readonly config: IntegrationConfig;
/** The plugin's typed store — the same instance the extension ctx sees.
* Lets spec-derived plugins load build artifacts kept behind the storage
* facades (e.g. a content-addressed spec blob) instead of inlining them
* in `config`. */
readonly storage: TStore;
/** The connection whose tools are being resolved. */
readonly connection: ConnectionRef;
/** Which of the integration's declared auth methods the connection binds
* (`connection.template`), so multi-method integrations render the right
* one during discovery. `null` when the connection row isn't persisted yet. */
readonly template: AuthTemplateSlug | null;
/** Lazily resolve the connection's credential value via its provider — only
* the kinds that actually call out (mcp) pay for it. */
readonly getValue: () => Effect.Effect<string | null, StorageFailure>;
/** Lazily resolve every credential input (`variable → value`) — the
* multi-input analog of `getValue`, for methods whose placements reference
* more than one variable. Empty map when the connection isn't persisted. */
readonly getValues: () => Effect.Effect<Record<string, string | null>, StorageFailure>;
}
export interface ResolveToolsResult {
readonly tools: readonly ToolDef[];
/** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */
readonly definitions?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Resolved credential handed to `invokeTool` so the plugin renders auth onto
// the request (D11: "auth state derived into the auth-template format").
// ---------------------------------------------------------------------------
export interface ToolInvocationCredential {
readonly owner: Owner;
readonly integration: IntegrationSlug;
readonly connection: ConnectionName;
readonly template: AuthTemplateSlug;
/** The primary (`token`) resolved value — for OAuth (the access token) and
* single-input apiKey methods. Equals `values.token`. */
readonly value: string | null;
/** Every resolved credential input (`variable → value`) for the connection.
* Single-input methods have just `{ token }`; an apiKey method with two
* distinct inputs (e.g. Datadog) has one entry per template variable. The
* render layer substitutes each `variable("<name>")` from this map. */
readonly values: Record<string, string | null>;
/** The integration's stored config, for template rendering. */
readonly config: IntegrationConfig;
}
// ---------------------------------------------------------------------------
// Static tool / source declarations. Unchanged from v1 except the ctx shape.
// ---------------------------------------------------------------------------
export interface StaticToolHandlerInput<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
readonly args: unknown;
readonly elicit: Elicit;
}
export interface StaticToolExecuteContext<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
readonly elicit: Elicit;
}
export type StaticToolSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
StandardJSONSchemaV1<Input, Output>;
export interface StaticToolDecl<TStore = unknown> {
readonly name: string;
readonly description: string;
readonly inputSchema?: StaticToolSchema;
readonly outputSchema?: StaticToolSchema;
readonly annotations?: ToolAnnotations;
readonly handler: (input: StaticToolHandlerInput<TStore>) => Effect.Effect<unknown, unknown>;
}
const decodeStaticToolArgs = (
schema: StaticToolSchema | undefined,
args: unknown,
): Effect.Effect<unknown, unknown> => {
if (schema == null) return Effect.succeed(args);
return Effect.promise(() => Promise.resolve(schema["~standard"].validate(args))).pipe(
Effect.flatMap((result) =>
"value" in result ? Effect.succeed(result.value) : Effect.fail(result),
),
);
};
export interface StaticToolInput<
TStore = unknown,
TInputSchema extends StaticToolSchema | undefined = StaticToolSchema | undefined,
> {
readonly name: string;
readonly description: string;
readonly inputSchema?: TInputSchema;
readonly outputSchema?: StaticToolSchema;
readonly annotations?: ToolAnnotations;
readonly execute: (
args: TInputSchema extends StaticToolSchema
? StandardSchemaV1.InferOutput<TInputSchema>
: unknown,
context: StaticToolExecuteContext<TStore>,
) => Effect.Effect<unknown, unknown>;
}
export const tool = <
TStore = unknown,
TInputSchema extends StaticToolSchema | undefined = StaticToolSchema | undefined,
>(
input: StaticToolInput<TStore, TInputSchema>,
): StaticToolDecl<TStore> => ({
name: input.name,
description: input.description,
inputSchema: input.inputSchema,
outputSchema: input.outputSchema,
annotations: input.annotations,
handler: ({ args, ctx, elicit }) =>
decodeStaticToolArgs(input.inputSchema, args).pipe(
Effect.flatMap((decoded) =>
input.execute(
decoded as TInputSchema extends StaticToolSchema
? StandardSchemaV1.InferOutput<TInputSchema>
: unknown,
{ ctx, elicit },
),
),
),
});
export interface StaticSourceDecl<TStore = unknown> {
readonly id: string;
readonly kind: string;
readonly name: string;
readonly url?: string;
readonly canRemove?: boolean;
readonly canRefresh?: boolean;
readonly canEdit?: boolean;
readonly tools: readonly StaticToolDecl<TStore>[];
}
// ---------------------------------------------------------------------------
// Dynamic invoke / connection lifecycle inputs.
// ---------------------------------------------------------------------------
export interface InvokeToolInput<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
/** Already-loaded per-connection tool row (carries integration, connection,
* owner, name, schemas). */
readonly toolRow: ToolInvocationRow;
/** The resolved credential to apply to the outbound request. */
readonly credential: ToolInvocationCredential;
readonly args: unknown;
readonly elicit: Elicit;
}
/** Called when the executor removes / refreshes a connection owned by this
* plugin's integration — plugin-side cleanup or re-resolution only; the
* executor handles the core tool rows. */
export interface ConnectionLifecycleInput<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
readonly integration: IntegrationSlug;
readonly connection: ConnectionRef;
}
export interface ConfigureIntegrationHandlerInput<TStore = unknown> {
readonly ctx: PluginCtx<TStore>;
readonly integration: IntegrationSlug;
readonly config: unknown;
}
export interface IntegrationConfigureDecl<TStore = unknown> {
readonly type: string;
readonly schema?: StaticToolSchema | EffectSchema.Decoder<unknown, never>;
readonly configure: (
input: ConfigureIntegrationHandlerInput<TStore>,
) => Effect.Effect<unknown, unknown>;
}
export interface IntegrationConfigureSchema {
readonly pluginId: string;
readonly type: string;
readonly schema?: unknown;
}
export interface IntegrationPreset {
readonly id: string;
readonly name: string;
readonly summary: string;
readonly url?: string;
readonly endpoint?: string;
readonly icon?: string;
readonly featured?: boolean;
readonly transport?: "remote" | "stdio";
readonly command?: string;
readonly args?: readonly string[];
readonly env?: Readonly<Record<string, string>>;
}
export interface IntegrationPresetCatalogEntry extends IntegrationPreset {
readonly pluginId: string;
}
// ---------------------------------------------------------------------------
// PluginSpec — kept from v1 wholesale; only the data-model hooks change.
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface PluginSpec<
TId extends string = string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtension extends object = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TStore = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtensionService extends Context.Service<any, any> | undefined = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
THandlersLayer extends Layer.Layer<any, any, any> = any,
TGroup extends HttpApiGroup.Any = HttpApiGroup.Any,
> {
readonly id: TId;
/** npm package name. The Vite plugin uses this to derive the `./client`
* import path for the frontend bundle. */
readonly packageName?: string;
/** Build the plugin's typed store from host-owned backing. */
readonly storage: (deps: StorageDeps) => TStore;
/** Host-owned plugin storage declarations. */
readonly pluginStorage?: PluginStorageConfig;
/** JSON-serializable config the plugin wants its `./client` bundle to see. */
readonly clientConfig?: unknown;
/** Integration presets shown by the web UI's "Popular integrations" list. */
readonly integrationPresets?: readonly IntegrationPreset[];
/** Build the plugin's extension API — becomes `executor[plugin.id]` and the
* `self` passed to `staticSources`. Field order matters: `extension` MUST
* appear before `staticSources`. */
readonly extension?: (ctx: PluginCtx<TStore>) => TExtension;
/** Static sources contributed by this plugin with inline tool handlers. */
readonly staticSources?: (self: NoInfer<TExtension>) => readonly StaticSourceDecl<TStore>[];
/** HttpApiGroup contributed by this plugin. */
readonly routes?: () => TGroup;
/** Handlers Layer for this plugin's group. */
readonly handlers?: () => THandlersLayer;
/** Service tag the plugin's `handlers` layer requires. */
readonly extensionService?: TExtensionService;
/** Produce a connection's tools (and shared $defs). The v2 successor to
* registering per-source tools — called by the executor at connection
* create / refresh / oauth.complete; the result is stamped with addresses
* and persisted per-connection. Omit for plugins with no dynamic tools. */
readonly resolveTools?: (
input: ResolveToolsInput<TStore>,
) => Effect.Effect<ResolveToolsResult, StorageFailure>;
/** Invoke a dynamic tool. Called when the static-handler map doesn't have the
* address. The plugin applies `input.credential` to the outbound request. */
readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
/** Bulk resolve annotations for a set of tool rows under one connection. */
readonly resolveAnnotations?: (input: {
readonly ctx: PluginCtx<TStore>;
readonly integration: IntegrationSlug;
readonly connection: ConnectionName;
readonly toolRows: readonly ToolInvocationRow[];
}) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
/** Plugin-side cleanup when a connection is removed. */
readonly removeConnection?: (
input: ConnectionLifecycleInput<TStore>,
) => Effect.Effect<void, unknown>;
/** Core-dispatched integration configuration (beyond auth). */
readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;
/** Project this plugin's opaque integration config into catalog-visible
* declared auth methods. Synchronous and pure (the config is already loaded);
* must tolerate a malformed/foreign config blob by returning `[]`. Absent ⇒
* core surfaces `[]` (the client falls through to its generic fallback). */
readonly describeAuthMethods?: (
integration: IntegrationRecord,
) => readonly AuthMethodDescriptor[];
/** Project this plugin's opaque integration config into safe catalog-visible
* display metadata. This is intentionally narrow: the client needs a URL for
* favicons without receiving the full plugin config. */
readonly describeIntegrationDisplay?: (
integration: IntegrationRecord,
) => IntegrationDisplayDescriptor;
/** URL autodetection hook for onboarding. */
readonly detect?: (input: {
readonly ctx: PluginCtx<TStore>;
readonly url: string;
}) => Effect.Effect<IntegrationDetectionResult | null, unknown>;
/** Credential providers contributed by this plugin (keychain, file, vault, …).
* The v2 successor to `secretProviders`. */
readonly credentialProviders?:
| readonly CredentialProvider[]
| ((ctx: PluginCtx<TStore>) => readonly CredentialProvider[])
| ((ctx: PluginCtx<TStore>) => Effect.Effect<readonly CredentialProvider[]>);
readonly close?: () => Effect.Effect<void, unknown>;
}
export interface Plugin<
TId extends string = string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtension extends object = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TStore = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtensionService extends Context.Service<any, any> | undefined = any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
THandlersLayer extends Layer.Layer<any, any, any> = any,
TGroup extends HttpApiGroup.Any = HttpApiGroup.Any,
> extends PluginSpec<TId, TExtension, TStore, TExtensionService, THandlersLayer, TGroup> {}
// ---------------------------------------------------------------------------
// definePlugin — factory-returning-spec.
// ---------------------------------------------------------------------------
export type ConfiguredPlugin<
TId extends string,
TExtension extends object,
TStore,
TOptions extends object,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtensionService extends Context.Service<any, any> | undefined = undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
THandlersLayer extends Layer.Layer<any, any, any> = Layer.Layer<unknown, never, never>,
TGroup extends HttpApiGroup.Any = HttpApiGroup.Any,
> = (
options?: TOptions & {
readonly storage?: (deps: StorageDeps) => TStore;
},
) => Plugin<TId, TExtension, TStore, TExtensionService, THandlersLayer, TGroup>;
// eslint-disable-next-line @typescript-eslint/ban-types
export function definePlugin<
TId extends string,
TExtension extends object,
TStore,
TOptions extends object = {},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TExtensionService extends Context.Service<any, any> | undefined = undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
THandlersLayer extends Layer.Layer<any, any, any> = Layer.Layer<unknown, never, never>,
TGroup extends HttpApiGroup.Any = HttpApiGroup.Any,
>(
authorFactory: (
options?: TOptions,
) => PluginSpec<TId, TExtension, TStore, TExtensionService, THandlersLayer, TGroup>,
): ConfiguredPlugin<TId, TExtension, TStore, TOptions, TExtensionService, THandlersLayer, TGroup> {
return (options) => {
const {
storage: storageOverride,
...rest
}: {
storage?: (deps: StorageDeps) => TStore;
[key: string]: unknown;
} = options ?? {};
const hasAuthorOptions = Object.keys(rest).length > 0;
const spec = authorFactory(hasAuthorOptions ? (rest as TOptions) : undefined);
return {
...spec,
storage: storageOverride ?? spec.storage,
};
};
}
// ---------------------------------------------------------------------------
// AnyPlugin / PluginExtensions — type-level glue for the Executor surface.
// ---------------------------------------------------------------------------
export type AnyPlugin = Plugin<string>;
export type PluginExtensions<TPlugins extends readonly AnyPlugin[]> = {
readonly [P in TPlugins[number] as P["id"]]: P extends Plugin<string, infer TExt> ? TExt : never;
};
// Re-exported for consumers that check the elicitation handler type.
export type { ElicitationHandler };