@tagma/sdk 0.6.0 → 0.6.2

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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +573 -573
  3. package/dist/bootstrap.d.ts +11 -1
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +18 -9
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/drivers/opencode.d.ts.map +1 -1
  8. package/dist/drivers/opencode.js +47 -17
  9. package/dist/drivers/opencode.js.map +1 -1
  10. package/dist/engine.d.ts +8 -0
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +17 -16
  13. package/dist/engine.js.map +1 -1
  14. package/dist/plugin-registry.test.d.ts +2 -0
  15. package/dist/plugin-registry.test.d.ts.map +1 -0
  16. package/dist/plugin-registry.test.js +188 -0
  17. package/dist/plugin-registry.test.js.map +1 -0
  18. package/dist/registry.d.ts +52 -28
  19. package/dist/registry.d.ts.map +1 -1
  20. package/dist/registry.js +126 -91
  21. package/dist/registry.js.map +1 -1
  22. package/dist/sdk.d.ts +1 -1
  23. package/dist/sdk.d.ts.map +1 -1
  24. package/dist/sdk.js +1 -1
  25. package/dist/sdk.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/bootstrap.ts +46 -37
  28. package/src/completions/output-check.ts +92 -92
  29. package/src/dag.ts +245 -245
  30. package/src/drivers/opencode.ts +410 -371
  31. package/src/engine.ts +1228 -1220
  32. package/src/hooks.ts +193 -193
  33. package/src/middlewares/static-context.ts +49 -49
  34. package/src/pipeline-runner.ts +173 -173
  35. package/src/plugin-registry.test.ts +230 -0
  36. package/src/prompt-doc.ts +49 -49
  37. package/src/registry.ts +316 -267
  38. package/src/runner.ts +460 -460
  39. package/src/schema.test.ts +101 -101
  40. package/src/schema.ts +338 -338
  41. package/src/sdk.ts +120 -118
  42. package/src/task-ref.test.ts +401 -401
  43. package/src/task-ref.ts +120 -120
  44. package/src/validate-raw.ts +412 -412
  45. package/dist/drivers/claude-code.d.ts +0 -3
  46. package/dist/drivers/claude-code.d.ts.map +0 -1
  47. package/dist/drivers/claude-code.js +0 -225
  48. package/dist/drivers/claude-code.js.map +0 -1
package/src/registry.ts CHANGED
@@ -1,267 +1,316 @@
1
- import { createRequire } from 'node:module';
2
- import { pathToFileURL } from 'node:url';
3
- import type {
4
- PluginCategory,
5
- DriverPlugin,
6
- TriggerPlugin,
7
- CompletionPlugin,
8
- MiddlewarePlugin,
9
- PluginManifest,
10
- } from './types';
11
-
12
- type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
13
-
14
- const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
15
- 'drivers',
16
- 'triggers',
17
- 'completions',
18
- 'middlewares',
19
- ]);
20
-
21
- const registries = {
22
- drivers: new Map<string, DriverPlugin>(),
23
- triggers: new Map<string, TriggerPlugin>(),
24
- completions: new Map<string, CompletionPlugin>(),
25
- middlewares: new Map<string, MiddlewarePlugin>(),
26
- };
27
-
28
- /**
29
- * Minimal contract enforcement so a malformed plugin fails fast at
30
- * registration time rather than crashing the engine mid-run.
31
- *
32
- * For drivers we materialize `capabilities` and assert each field is a
33
- * boolean otherwise a plugin author can write
34
- * get capabilities() { throw new Error('boom') }
35
- * and pass the basic typeof check, then crash preflight when the engine
36
- * touches `driver.capabilities.sessionResume`. (R8)
37
- */
38
- function validateContract(category: PluginCategory, handler: unknown): void {
39
- if (!handler || typeof handler !== 'object') {
40
- throw new Error(`Plugin handler for category "${category}" must be an object`);
41
- }
42
- const h = handler as Record<string, unknown>;
43
- if (typeof h.name !== 'string' || h.name.length === 0) {
44
- throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
45
- }
46
- switch (category) {
47
- case 'drivers': {
48
- if (typeof h.buildCommand !== 'function') {
49
- throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
50
- }
51
- // Materialize capabilities this triggers any throwing getter NOW
52
- // instead of during preflight.
53
- let caps: unknown;
54
- try {
55
- caps = h.capabilities;
56
- } catch (err) {
57
- throw new Error(
58
- `drivers plugin "${h.name}" capabilities accessor threw: ` +
59
- (err instanceof Error ? err.message : String(err)),
60
- );
61
- }
62
- if (!caps || typeof caps !== 'object') {
63
- throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
64
- }
65
- const c = caps as Record<string, unknown>;
66
- for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
67
- if (typeof c[field] !== 'boolean') {
68
- throw new Error(
69
- `drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`,
70
- );
71
- }
72
- }
73
- // Optional methods, but if present must be functions.
74
- for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
75
- if (h[opt] !== undefined && typeof h[opt] !== 'function') {
76
- throw new Error(`drivers plugin "${h.name}".${opt} must be a function or undefined`);
77
- }
78
- }
79
- break;
80
- }
81
- case 'triggers':
82
- if (typeof h.watch !== 'function') {
83
- throw new Error(`triggers plugin "${h.name}" must export watch()`);
84
- }
85
- break;
86
- case 'completions':
87
- if (typeof h.check !== 'function') {
88
- throw new Error(`completions plugin "${h.name}" must export check()`);
89
- }
90
- break;
91
- case 'middlewares':
92
- // A middleware must provide at least one entry point. `enhanceDoc` is
93
- // the structured PromptDocument API (preferred); `enhance` is the
94
- // legacy string-in/string-out API the engine still supports for
95
- // v0.x plugins. Requiring only `enhance` here rejects every built-in
96
- // and every plugin written against the current types.
97
- if (typeof h.enhanceDoc !== 'function' && typeof h.enhance !== 'function') {
98
- throw new Error(
99
- `middlewares plugin "${h.name}" must export enhanceDoc() or enhance()`,
100
- );
101
- }
102
- if (h.enhanceDoc !== undefined && typeof h.enhanceDoc !== 'function') {
103
- throw new Error(`middlewares plugin "${h.name}".enhanceDoc must be a function or undefined`);
104
- }
105
- if (h.enhance !== undefined && typeof h.enhance !== 'function') {
106
- throw new Error(`middlewares plugin "${h.name}".enhance must be a function or undefined`);
107
- }
108
- break;
109
- }
110
- }
111
-
112
- export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
113
-
114
- /**
115
- * Register a plugin under (category, type). Returns:
116
- * - 'registered' on first registration
117
- * - 'replaced' when an existing entry was overwritten with a different handler
118
- * - 'unchanged' when the same handler instance was already present
119
- *
120
- * Throws if `category` is unknown, `type` is empty, or `handler` violates the
121
- * minimum interface contract for the category.
122
- */
123
- export function registerPlugin<T extends PluginType>(
124
- category: PluginCategory,
125
- type: string,
126
- handler: T,
127
- ): RegisterResult {
128
- if (!VALID_CATEGORIES.has(category)) {
129
- throw new Error(`Unknown plugin category "${category}"`);
130
- }
131
- if (typeof type !== 'string' || type.length === 0) {
132
- throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
133
- }
134
- validateContract(category, handler);
135
- const registry = registries[category] as Map<string, T>;
136
- const existing = registry.get(type);
137
- if (existing === handler) return 'unchanged';
138
- const wasReplaced = existing !== undefined;
139
- registry.set(type, handler);
140
- if (wasReplaced) {
141
- // D18: surface silent shadowing. Hot-reload flows legitimately replace
142
- // handlers; installing two different plugin packages that both claim
143
- // the same (category, type) does not — the second wins and breaks the
144
- // first's consumers with no audit trail. A console.warn is cheap,
145
- // respects existing callers that rely on 'replaced', and gives ops a
146
- // grep-able signal when registrations collide unexpectedly.
147
- console.warn(
148
- `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
149
- `check for duplicate plugin packages claiming the same type.`,
150
- );
151
- }
152
- return wasReplaced ? 'replaced' : 'registered';
153
- }
154
-
155
- /**
156
- * Remove a plugin from the in-process registry. Returns true if a plugin
157
- * was actually removed. Note: ESM module caching is not affected, so
158
- * re-importing the same file after unregister will yield the cached module —
159
- * callers wanting a fresh load must restart the host process.
160
- */
161
- export function unregisterPlugin(category: PluginCategory, type: string): boolean {
162
- if (!VALID_CATEGORIES.has(category)) return false;
163
- return registries[category].delete(type);
164
- }
165
-
166
- export function getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
167
- const handler = registries[category].get(type);
168
- if (!handler) {
169
- throw new Error(
170
- `${category} type "${type}" not registered.\n` +
171
- `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`,
172
- );
173
- }
174
- return handler as T;
175
- }
176
-
177
- export function hasHandler(category: PluginCategory, type: string): boolean {
178
- return registries[category].has(type);
179
- }
180
-
181
- // Plugin name must be a scoped npm package or a tagma-prefixed package.
182
- // Reject absolute/relative paths and suspicious patterns to prevent
183
- // arbitrary code execution via crafted YAML configs.
184
- export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
185
-
186
- export function isValidPluginName(name: unknown): name is string {
187
- return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
188
- }
189
-
190
- /**
191
- * Parse and validate the `tagmaPlugin` field of a `package.json` blob.
192
- *
193
- * Returns the strongly-typed manifest if the field is present and
194
- * well-formed (`category` is one of the four known categories and `type`
195
- * is a non-empty string). Returns `null` if the field is absent that
196
- * is the host's signal that the package is a library, not a plugin.
197
- *
198
- * Throws if the field is present but malformed: that's a packaging bug
199
- * the plugin author should hear about loudly, not a silent skip.
200
- *
201
- * Hosts use this during auto-discovery to decide whether to load a
202
- * package as a plugin without having to dynamically `import()` it.
203
- */
204
- export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
205
- if (!pkgJson || typeof pkgJson !== 'object') return null;
206
- const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
207
- if (raw === undefined) return null;
208
- if (!raw || typeof raw !== 'object') {
209
- throw new Error('tagmaPlugin field must be an object with { category, type }');
210
- }
211
- const m = raw as Record<string, unknown>;
212
- const category = m.category;
213
- const type = m.type;
214
- if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
215
- throw new Error(
216
- `tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`,
217
- );
218
- }
219
- if (typeof type !== 'string' || type.length === 0) {
220
- throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
221
- }
222
- return { category: category as PluginCategory, type };
223
- }
224
-
225
- /**
226
- * Load and register a list of plugin packages.
227
- *
228
- * @param pluginNames - Validated npm package names to load.
229
- * @param resolveFrom - Optional absolute path to resolve plugins from (e.g. the
230
- * workspace's working directory). When omitted, the default ESM resolution
231
- * uses the SDK's own `node_modules`, which will fail for plugins installed
232
- * only in the user's workspace. CLI callers should pass `process.cwd()` or
233
- * the workspace root so that workspace-local plugins resolve correctly.
234
- */
235
- export async function loadPlugins(
236
- pluginNames: readonly string[],
237
- resolveFrom?: string,
238
- ): Promise<void> {
239
- for (const name of pluginNames) {
240
- if (!isValidPluginName(name)) {
241
- throw new Error(
242
- `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
243
- `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
244
- `Relative/absolute paths are not allowed.`,
245
- );
246
- }
247
- let moduleUrl: string = name;
248
- if (resolveFrom) {
249
- // Resolve the package entry point relative to the caller's directory so
250
- // plugins installed in the workspace's node_modules are found even when
251
- // the SDK itself lives elsewhere (e.g. a global install or a monorepo
252
- // sibling package).
253
- const req = createRequire(resolveFrom.endsWith('/') ? resolveFrom : resolveFrom + '/');
254
- const resolved = req.resolve(name);
255
- moduleUrl = pathToFileURL(resolved).href;
256
- }
257
- const mod = await import(moduleUrl);
258
- if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
259
- throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
260
- }
261
- registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
262
- }
263
- }
264
-
265
- export function listRegistered(category: PluginCategory): string[] {
266
- return [...registries[category].keys()];
267
- }
1
+ import { createRequire } from 'node:module';
2
+ import { pathToFileURL } from 'node:url';
3
+ import type {
4
+ PluginCategory,
5
+ DriverPlugin,
6
+ TriggerPlugin,
7
+ CompletionPlugin,
8
+ MiddlewarePlugin,
9
+ PluginManifest,
10
+ } from './types';
11
+
12
+ type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
13
+
14
+ const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
15
+ 'drivers',
16
+ 'triggers',
17
+ 'completions',
18
+ 'middlewares',
19
+ ]);
20
+
21
+ /**
22
+ * Minimal contract enforcement so a malformed plugin fails fast at
23
+ * registration time rather than crashing the engine mid-run.
24
+ *
25
+ * For drivers we materialize `capabilities` and assert each field is a
26
+ * boolean — otherwise a plugin author can write
27
+ * get capabilities() { throw new Error('boom') }
28
+ * and pass the basic typeof check, then crash preflight when the engine
29
+ * touches `driver.capabilities.sessionResume`. (R8)
30
+ */
31
+ function validateContract(category: PluginCategory, handler: unknown): void {
32
+ if (!handler || typeof handler !== 'object') {
33
+ throw new Error(`Plugin handler for category "${category}" must be an object`);
34
+ }
35
+ const h = handler as Record<string, unknown>;
36
+ if (typeof h.name !== 'string' || h.name.length === 0) {
37
+ throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
38
+ }
39
+ switch (category) {
40
+ case 'drivers': {
41
+ if (typeof h.buildCommand !== 'function') {
42
+ throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
43
+ }
44
+ // Materialize capabilities this triggers any throwing getter NOW
45
+ // instead of during preflight.
46
+ let caps: unknown;
47
+ try {
48
+ caps = h.capabilities;
49
+ } catch (err) {
50
+ throw new Error(
51
+ `drivers plugin "${h.name}" capabilities accessor threw: ` +
52
+ (err instanceof Error ? err.message : String(err)),
53
+ );
54
+ }
55
+ if (!caps || typeof caps !== 'object') {
56
+ throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
57
+ }
58
+ const c = caps as Record<string, unknown>;
59
+ for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
60
+ if (typeof c[field] !== 'boolean') {
61
+ throw new Error(
62
+ `drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`,
63
+ );
64
+ }
65
+ }
66
+ // Optional methods, but if present must be functions.
67
+ for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
68
+ if (h[opt] !== undefined && typeof h[opt] !== 'function') {
69
+ throw new Error(`drivers plugin "${h.name}".${opt} must be a function or undefined`);
70
+ }
71
+ }
72
+ break;
73
+ }
74
+ case 'triggers':
75
+ if (typeof h.watch !== 'function') {
76
+ throw new Error(`triggers plugin "${h.name}" must export watch()`);
77
+ }
78
+ break;
79
+ case 'completions':
80
+ if (typeof h.check !== 'function') {
81
+ throw new Error(`completions plugin "${h.name}" must export check()`);
82
+ }
83
+ break;
84
+ case 'middlewares':
85
+ // A middleware must provide at least one entry point. `enhanceDoc` is
86
+ // the structured PromptDocument API (preferred); `enhance` is the
87
+ // legacy string-in/string-out API the engine still supports for
88
+ // v0.x plugins. Requiring only `enhance` here rejects every built-in
89
+ // and every plugin written against the current types.
90
+ if (typeof h.enhanceDoc !== 'function' && typeof h.enhance !== 'function') {
91
+ throw new Error(
92
+ `middlewares plugin "${h.name}" must export enhanceDoc() or enhance()`,
93
+ );
94
+ }
95
+ if (h.enhanceDoc !== undefined && typeof h.enhanceDoc !== 'function') {
96
+ throw new Error(`middlewares plugin "${h.name}".enhanceDoc must be a function or undefined`);
97
+ }
98
+ if (h.enhance !== undefined && typeof h.enhance !== 'function') {
99
+ throw new Error(`middlewares plugin "${h.name}".enhance must be a function or undefined`);
100
+ }
101
+ break;
102
+ }
103
+ }
104
+
105
+ export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
106
+
107
+ // Plugin name must be a scoped npm package or a tagma-prefixed package.
108
+ // Reject absolute/relative paths and suspicious patterns to prevent
109
+ // arbitrary code execution via crafted YAML configs.
110
+ export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
111
+
112
+ export function isValidPluginName(name: unknown): name is string {
113
+ return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
114
+ }
115
+
116
+ /**
117
+ * Parse and validate the `tagmaPlugin` field of a `package.json` blob.
118
+ *
119
+ * Returns the strongly-typed manifest if the field is present and
120
+ * well-formed (`category` is one of the four known categories and `type`
121
+ * is a non-empty string). Returns `null` if the field is absent — that
122
+ * is the host's signal that the package is a library, not a plugin.
123
+ *
124
+ * Throws if the field is present but malformed: that's a packaging bug
125
+ * the plugin author should hear about loudly, not a silent skip.
126
+ *
127
+ * Hosts use this during auto-discovery to decide whether to load a
128
+ * package as a plugin without having to dynamically `import()` it.
129
+ */
130
+ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
131
+ if (!pkgJson || typeof pkgJson !== 'object') return null;
132
+ const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
133
+ if (raw === undefined) return null;
134
+ if (!raw || typeof raw !== 'object') {
135
+ throw new Error('tagmaPlugin field must be an object with { category, type }');
136
+ }
137
+ const m = raw as Record<string, unknown>;
138
+ const category = m.category;
139
+ const type = m.type;
140
+ if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
141
+ throw new Error(
142
+ `tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`,
143
+ );
144
+ }
145
+ if (typeof type !== 'string' || type.length === 0) {
146
+ throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
147
+ }
148
+ return { category: category as PluginCategory, type };
149
+ }
150
+
151
+ /**
152
+ * Instance-scoped plugin registry. Each workspace in a multi-tenant sidecar
153
+ * owns its own PluginRegistry, so installing/uninstalling a driver in one
154
+ * workspace cannot clobber another. The process-wide `defaultRegistry`
155
+ * exported at the bottom of this file preserves the historical free-function
156
+ * API (registerPlugin / getHandler / …) for CLI and single-tenant hosts.
157
+ */
158
+ export class PluginRegistry {
159
+ private readonly registries = {
160
+ drivers: new Map<string, DriverPlugin>(),
161
+ triggers: new Map<string, TriggerPlugin>(),
162
+ completions: new Map<string, CompletionPlugin>(),
163
+ middlewares: new Map<string, MiddlewarePlugin>(),
164
+ };
165
+
166
+ /**
167
+ * Register a plugin under (category, type). Returns:
168
+ * - 'registered' on first registration
169
+ * - 'replaced' when an existing entry was overwritten with a different handler
170
+ * - 'unchanged' when the same handler instance was already present
171
+ *
172
+ * Throws if `category` is unknown, `type` is empty, or `handler` violates
173
+ * the minimum interface contract for the category.
174
+ */
175
+ registerPlugin<T extends PluginType>(
176
+ category: PluginCategory,
177
+ type: string,
178
+ handler: T,
179
+ ): RegisterResult {
180
+ if (!VALID_CATEGORIES.has(category)) {
181
+ throw new Error(`Unknown plugin category "${category}"`);
182
+ }
183
+ if (typeof type !== 'string' || type.length === 0) {
184
+ throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
185
+ }
186
+ validateContract(category, handler);
187
+ const registry = this.registries[category] as Map<string, T>;
188
+ const existing = registry.get(type);
189
+ if (existing === handler) return 'unchanged';
190
+ const wasReplaced = existing !== undefined;
191
+ registry.set(type, handler);
192
+ if (wasReplaced) {
193
+ // D18: surface silent shadowing. Hot-reload flows legitimately replace
194
+ // handlers; installing two different plugin packages that both claim
195
+ // the same (category, type) does not the second wins and breaks the
196
+ // first's consumers with no audit trail. A console.warn is cheap,
197
+ // respects existing callers that rely on 'replaced', and gives ops a
198
+ // grep-able signal when registrations collide unexpectedly.
199
+ console.warn(
200
+ `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
201
+ `check for duplicate plugin packages claiming the same type.`,
202
+ );
203
+ }
204
+ return wasReplaced ? 'replaced' : 'registered';
205
+ }
206
+
207
+ /**
208
+ * Remove a plugin from the in-process registry. Returns true if a plugin
209
+ * was actually removed. Note: ESM module caching is not affected, so
210
+ * re-importing the same file after unregister will yield the cached module —
211
+ * callers wanting a fresh load must restart the host process.
212
+ */
213
+ unregisterPlugin(category: PluginCategory, type: string): boolean {
214
+ if (!VALID_CATEGORIES.has(category)) return false;
215
+ return this.registries[category].delete(type);
216
+ }
217
+
218
+ getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
219
+ const handler = this.registries[category].get(type);
220
+ if (!handler) {
221
+ throw new Error(
222
+ `${category} type "${type}" not registered.\n` +
223
+ `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`,
224
+ );
225
+ }
226
+ return handler as T;
227
+ }
228
+
229
+ hasHandler(category: PluginCategory, type: string): boolean {
230
+ return this.registries[category].has(type);
231
+ }
232
+
233
+ listRegistered(category: PluginCategory): string[] {
234
+ return [...this.registries[category].keys()];
235
+ }
236
+
237
+ /**
238
+ * Load and register a list of plugin packages into this registry.
239
+ *
240
+ * @param pluginNames - Validated npm package names to load.
241
+ * @param resolveFrom - Optional absolute path to resolve plugins from (e.g.
242
+ * the workspace's working directory). When omitted, the default ESM
243
+ * resolution uses the SDK's own `node_modules`, which will fail for
244
+ * plugins installed only in the user's workspace. CLI callers should
245
+ * pass `process.cwd()` or the workspace root so that workspace-local
246
+ * plugins resolve correctly.
247
+ */
248
+ async loadPlugins(
249
+ pluginNames: readonly string[],
250
+ resolveFrom?: string,
251
+ ): Promise<void> {
252
+ for (const name of pluginNames) {
253
+ if (!isValidPluginName(name)) {
254
+ throw new Error(
255
+ `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
256
+ `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
257
+ `Relative/absolute paths are not allowed.`,
258
+ );
259
+ }
260
+ let moduleUrl: string = name;
261
+ if (resolveFrom) {
262
+ // Resolve the package entry point relative to the caller's directory
263
+ // so plugins installed in the workspace's node_modules are found
264
+ // even when the SDK itself lives elsewhere (e.g. a global install
265
+ // or a monorepo sibling package).
266
+ const req = createRequire(resolveFrom.endsWith('/') ? resolveFrom : resolveFrom + '/');
267
+ const resolved = req.resolve(name);
268
+ moduleUrl = pathToFileURL(resolved).href;
269
+ }
270
+ const mod = await import(moduleUrl);
271
+ if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
272
+ throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
273
+ }
274
+ this.registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Process-wide default registry. Preserves the historical free-function API
281
+ * for CLI and single-tenant hosts. Multi-tenant hosts (the editor sidecar
282
+ * after the one-sidecar refactor) build their own `PluginRegistry` per
283
+ * workspace and pass it through `RunPipelineOptions.registry`.
284
+ */
285
+ export const defaultRegistry = new PluginRegistry();
286
+
287
+ export function registerPlugin<T extends PluginType>(
288
+ category: PluginCategory,
289
+ type: string,
290
+ handler: T,
291
+ ): RegisterResult {
292
+ return defaultRegistry.registerPlugin(category, type, handler);
293
+ }
294
+
295
+ export function unregisterPlugin(category: PluginCategory, type: string): boolean {
296
+ return defaultRegistry.unregisterPlugin(category, type);
297
+ }
298
+
299
+ export function getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
300
+ return defaultRegistry.getHandler<T>(category, type);
301
+ }
302
+
303
+ export function hasHandler(category: PluginCategory, type: string): boolean {
304
+ return defaultRegistry.hasHandler(category, type);
305
+ }
306
+
307
+ export function listRegistered(category: PluginCategory): string[] {
308
+ return defaultRegistry.listRegistered(category);
309
+ }
310
+
311
+ export function loadPlugins(
312
+ pluginNames: readonly string[],
313
+ resolveFrom?: string,
314
+ ): Promise<void> {
315
+ return defaultRegistry.loadPlugins(pluginNames, resolveFrom);
316
+ }