pi-lazy-extensions 0.1.1

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/registry.ts ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Extension registry - loads and tracks lazy extensions.
3
+ *
4
+ * This is the core of pi-lazy-extensions. It handles:
5
+ * - Dynamic loading of extensions via jiti (or import()) + factory(pi)
6
+ * - Tracking registered tools/commands per extension
7
+ * - Idle timeout unloading
8
+ * - Deduplication guards
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { ActivationResult, LazyExtensionsState } from "./types.js";
13
+ import { resolveExtensionPath } from "./config.js";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const FAILURE_BACKOFF_MS = 60 * 1000;
17
+
18
+ /**
19
+ * Deduplicated entry point for activating an extension.
20
+ * Guards against concurrent activation of the same extension.
21
+ */
22
+ export async function activateExtension(
23
+ name: string,
24
+ state: LazyExtensionsState,
25
+ pi: ExtensionAPI,
26
+ ): Promise<ActivationResult> {
27
+ const extState = state.extensions.get(name);
28
+ if (!extState) {
29
+ return { success: false, name, error: `Extension "${name}" not found in manifest` };
30
+ }
31
+
32
+ if (extState.loaded) {
33
+ touchExtension(name, state);
34
+ return {
35
+ success: true,
36
+ name,
37
+ tools: extState.registeredTools,
38
+ commands: extState.registeredCommands,
39
+ };
40
+ }
41
+
42
+ // --- Reactivation path (previously loaded, idle-unloaded) ---
43
+ // Calling factory(pi) again would double-register event handlers (bug).
44
+ // Instead, restore the already-registered tools to the active set.
45
+ // Use factoryCalled (not registeredTools.length) as the gate so that
46
+ // extensions registering zero tools don't fall through to a double
47
+ // factory(pi) call.
48
+ if (extState.factoryCalled) {
49
+ const currentActive = new Set(pi.getActiveTools());
50
+ const restored: string[] = [];
51
+ for (const toolName of extState.registeredTools) {
52
+ if (!currentActive.has(toolName)) {
53
+ restored.push(toolName);
54
+ }
55
+ }
56
+ if (restored.length > 0) {
57
+ pi.setActiveTools([...currentActive, ...restored]);
58
+ }
59
+
60
+ extState.loaded = true;
61
+ extState.lastActivated = Date.now();
62
+ extState.lastUsed = Date.now();
63
+ extState.error = undefined;
64
+ scheduleIdleTimeout(name, state, pi);
65
+
66
+ return {
67
+ success: true,
68
+ name,
69
+ tools: extState.registeredTools,
70
+ commands: extState.registeredCommands,
71
+ };
72
+ }
73
+
74
+ // Check failure backoff
75
+ const failedAgo = getFailureAgeSeconds(state, name);
76
+ if (failedAgo !== null) {
77
+ return {
78
+ success: false,
79
+ name,
80
+ error: `Extension "${name}" recently failed (${failedAgo}s ago). Retry later.`,
81
+ };
82
+ }
83
+
84
+ // Deduplicate concurrent activations of the same extension
85
+ if (extState.activationPromise) {
86
+ return extState.activationPromise;
87
+ }
88
+
89
+ // Serialize activations to prevent method-wrapping races and
90
+ // tool-diff misattribution between concurrent factory(pi) calls
91
+ // for different extensions.
92
+ extState.activationPromise = (async () => {
93
+ // Wait for any in-progress activation of a different extension
94
+ while (state.activationLock) {
95
+ await state.activationLock;
96
+ }
97
+
98
+ // Acquire the lock
99
+ let releaseLock!: () => void;
100
+ const lockPromise = new Promise<void>((resolve) => { releaseLock = resolve; });
101
+ state.activationLock = lockPromise;
102
+
103
+ try {
104
+ return await performActivation(name, state, pi);
105
+ } finally {
106
+ releaseLock();
107
+ // Only clear if we're still the current lock
108
+ if (state.activationLock === lockPromise) {
109
+ state.activationLock = undefined;
110
+ }
111
+ }
112
+ })();
113
+
114
+ try {
115
+ const result = await extState.activationPromise;
116
+ return result;
117
+ } finally {
118
+ extState.activationPromise = undefined;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Core first-time activation logic (wrapped by activateExtension for deduplication).
124
+ */
125
+ async function performActivation(
126
+ name: string,
127
+ state: LazyExtensionsState,
128
+ pi: ExtensionAPI,
129
+ ): Promise<ActivationResult> {
130
+ const extState = state.extensions.get(name)!;
131
+
132
+ // Snapshot current registries to diff after load
133
+ const toolsBefore = new Set(pi.getAllTools().map((t: any) => t.name as string));
134
+ const commandsBefore = new Set(pi.getCommands().map((c: any) => c.name as string));
135
+
136
+ // Wrap pi to intercept registrations during factory(pi).
137
+ // The ExtensionAPI doesn't expose getters for shortcuts, flags,
138
+ // renderers, event subscriptions, or attempted tool names, so we
139
+ // intercept them. The serialization lock ensures only one factory
140
+ // runs at a time, making these wrappers safe against races.
141
+ const interceptedShortcuts: string[] = [];
142
+ const interceptedFlags: string[] = [];
143
+ const interceptedRenderers: string[] = [];
144
+ const interceptedTools: string[] = [];
145
+ const interceptedEvents: string[] = [];
146
+
147
+ const origRegisterShortcut = (pi as any).registerShortcut?.bind(pi);
148
+ const origRegisterFlag = (pi as any).registerFlag?.bind(pi);
149
+ const origRegisterMessageRenderer = (pi as any).registerMessageRenderer?.bind(pi);
150
+ const origRegisterTool = pi.registerTool.bind(pi);
151
+ const origOn = pi.on.bind(pi);
152
+
153
+ (pi as any).registerShortcut = (shortcut: string, options: any) => {
154
+ interceptedShortcuts.push(shortcut);
155
+ origRegisterShortcut?.(shortcut, options);
156
+ };
157
+ (pi as any).registerFlag = (flagName: string, options: any) => {
158
+ interceptedFlags.push(flagName);
159
+ origRegisterFlag?.(flagName, options);
160
+ };
161
+ (pi as any).registerMessageRenderer = (customType: string, renderer: any) => {
162
+ interceptedRenderers.push(customType);
163
+ origRegisterMessageRenderer?.(customType, renderer);
164
+ };
165
+ pi.registerTool = (tool: any) => {
166
+ interceptedTools.push(tool.name);
167
+ origRegisterTool(tool);
168
+ };
169
+ pi.on = (event: string, handler: any) => {
170
+ interceptedEvents.push(event);
171
+ origOn(event, handler);
172
+ };
173
+
174
+ const extPath = resolveExtensionPath(extState.config.path, state.baseDir);
175
+
176
+ try {
177
+ const factory = await loadExtensionFactory(extPath);
178
+
179
+ if (!factory) {
180
+ const message = `Extension "${name}" does not export a default factory function`;
181
+ extState.error = message;
182
+ state.failureTracker.set(name, Date.now());
183
+ return { success: false, name, error: message };
184
+ }
185
+
186
+ // Invoke the extension factory with the shared ExtensionAPI.
187
+ // This registers tools, events, commands, etc. into the same runtime.
188
+ await factory(pi);
189
+
190
+ // Diff to find what this extension actually registered
191
+ const toolsAfter = pi.getAllTools().map((t: any) => t.name as string);
192
+ const newTools = toolsAfter.filter(t => !toolsBefore.has(t));
193
+ extState.registeredTools = newTools;
194
+
195
+ // Diff commands the same way
196
+ const commandsAfter = pi.getCommands().map((c: any) => c.name as string);
197
+ const newCommands = commandsAfter.filter(c => !commandsBefore.has(c));
198
+ extState.registeredCommands = newCommands;
199
+
200
+ // Track shortcuts, flags, and renderers (informational — cannot be deactivated)
201
+ // These were captured by our wrapped pi methods during factory(pi).
202
+ extState.registeredShortcuts = interceptedShortcuts;
203
+ extState.registeredFlags = interceptedFlags;
204
+ extState.registeredRenderers = interceptedRenderers;
205
+
206
+ // Detect duplicate tool names (silently skipped by "first registration wins")
207
+ const duplicateTools = interceptedTools.filter(t => toolsBefore.has(t));
208
+
209
+ // Detect session_start handlers — these won't fire for the current session
210
+ // since the session has already started. Warn the user.
211
+ const sessionStartWarning = interceptedEvents.includes("session_start");
212
+
213
+ extState.loaded = true;
214
+ extState.factoryCalled = true;
215
+ extState.lastActivated = Date.now();
216
+ extState.lastUsed = Date.now();
217
+ extState.error = undefined;
218
+ state.failureTracker.delete(name);
219
+
220
+ // Start idle timer if configured
221
+ scheduleIdleTimeout(name, state, pi);
222
+
223
+ return {
224
+ success: true,
225
+ name,
226
+ tools: newTools,
227
+ commands: extState.registeredCommands,
228
+ shortcuts: interceptedShortcuts,
229
+ flags: interceptedFlags,
230
+ renderers: interceptedRenderers,
231
+ duplicateTools: duplicateTools.length > 0 ? duplicateTools : undefined,
232
+ sessionStartWarning: sessionStartWarning || undefined,
233
+ };
234
+ } catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ extState.error = message;
237
+ state.failureTracker.set(name, Date.now());
238
+ return { success: false, name, error: message };
239
+ } finally {
240
+ // Always restore original pi methods, even on early return (!factory)
241
+ // or thrown errors. Without this, stale wrappers would corrupt future
242
+ // activations.
243
+ (pi as any).registerShortcut = origRegisterShortcut;
244
+ (pi as any).registerFlag = origRegisterFlag;
245
+ (pi as any).registerMessageRenderer = origRegisterMessageRenderer;
246
+ pi.registerTool = origRegisterTool;
247
+ pi.on = origOn;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Build the jiti alias map that mirrors pi's own extension loader.
253
+ *
254
+ * Pi bundles core packages (typebox, @earendil-works/pi-ai, etc.) and resolves
255
+ * them via aliases when loading extensions with jiti. Without these aliases,
256
+ * extensions that `import { Type } from "typebox"` or import from
257
+ * `@earendil-works/pi-coding-agent` will fail with module-not-found errors.
258
+ *
259
+ * We resolve the same packages via import.meta.resolve() which works
260
+ * because pi-lazy-extensions itself depends on @earendil-works/pi-coding-agent
261
+ * (the peer dependency is installed), and typebox is bundled by pi.
262
+ */
263
+ let _jitiAliases: Record<string, string> | undefined;
264
+
265
+ function buildJitiAliases(): Record<string, string> {
266
+ if (_jitiAliases) return _jitiAliases;
267
+
268
+ const aliases: Record<string, string> = {};
269
+
270
+ // Core pi packages that extensions commonly import
271
+ const piPackages = [
272
+ "@earendil-works/pi-coding-agent",
273
+ "@earendil-works/pi-agent-core",
274
+ "@earendil-works/pi-tui",
275
+ "@earendil-works/pi-ai",
276
+ "@earendil-works/pi-ai/oauth",
277
+ ] as const;
278
+
279
+ for (const pkg of piPackages) {
280
+ try {
281
+ aliases[pkg] = fileURLToPath(import.meta.resolve(pkg));
282
+ } catch {
283
+ // Package not available in this environment — skip
284
+ }
285
+ }
286
+
287
+ // typebox and its subpath exports (most extensions use `import { Type } from "typebox"`)
288
+ const typeboxSpecs = ["typebox", "typebox/compile", "typebox/value"] as const;
289
+ for (const spec of typeboxSpecs) {
290
+ try {
291
+ aliases[spec] = fileURLToPath(import.meta.resolve(spec));
292
+ } catch {
293
+ // typebox not available — skip
294
+ }
295
+ }
296
+
297
+ // Alias legacy @sinclair/typebox to the same entries (some extensions still use it)
298
+ if (aliases["typebox"]) {
299
+ aliases["@sinclair/typebox"] = aliases["typebox"];
300
+ }
301
+ if (aliases["typebox/compile"]) {
302
+ aliases["@sinclair/typebox/compile"] = aliases["typebox/compile"];
303
+ }
304
+ if (aliases["typebox/value"]) {
305
+ aliases["@sinclair/typebox/value"] = aliases["typebox/value"];
306
+ }
307
+
308
+ _jitiAliases = aliases;
309
+ return aliases;
310
+ }
311
+
312
+ /**
313
+ * Reset cached jiti aliases (for testing or after module changes).
314
+ */
315
+ export function resetJitiAliases(): void {
316
+ _jitiAliases = undefined;
317
+ }
318
+
319
+ /**
320
+ * Load an extension module and extract its default factory function.
321
+ *
322
+ * Tries jiti first (for TypeScript support and module alias resolution),
323
+ * then falls back to raw import() for .js files or when jiti is unavailable.
324
+ *
325
+ * Jiti is configured with the same alias map that pi's own extension loader
326
+ * uses, so extensions can import from `typebox`, `@earendil-works/pi-coding-agent`,
327
+ * etc. just like they would in a normally-loaded extension.
328
+ */
329
+ async function loadExtensionFactory(
330
+ extPath: string,
331
+ ): Promise<((pi: ExtensionAPI) => void | Promise<void>) | undefined> {
332
+ // Attempt 1: jiti — handles .ts and provides SDK-compatible module aliases
333
+ try {
334
+ const { createJiti } = await import("jiti");
335
+ const jiti = createJiti(import.meta.url, {
336
+ moduleCache: false,
337
+ alias: buildJitiAliases(),
338
+ });
339
+ const mod = await jiti.import(extPath, { default: true });
340
+ if (typeof mod === "function") {
341
+ return mod as (pi: ExtensionAPI) => void | Promise<void>;
342
+ }
343
+ if (typeof (mod as any)?.default === "function") {
344
+ return (mod as any).default as (pi: ExtensionAPI) => void | Promise<void>;
345
+ }
346
+ return undefined;
347
+ } catch {
348
+ // jiti unavailable or failed — fall through to raw import
349
+ }
350
+
351
+ // Attempt 2: raw import() — works for .js files, no TypeScript support
352
+ const mod = await import(extPath);
353
+ if (typeof mod.default === "function") {
354
+ return mod.default as (pi: ExtensionAPI) => void | Promise<void>;
355
+ }
356
+ return undefined;
357
+ }
358
+
359
+ /**
360
+ * Touch an extension's last-used timestamp and reschedule idle timer.
361
+ * Resets the timer to a full idleTimeout from now, preventing the timer
362
+ * from firing early if the extension was recently used.
363
+ */
364
+ export function touchExtension(name: string, state: LazyExtensionsState): void {
365
+ const extState = state.extensions.get(name);
366
+ if (extState && extState.loaded) {
367
+ extState.lastUsed = Date.now();
368
+ // Reschedule idle timer to start from now, preventing the timer
369
+ // from firing early if the extension was recently used.
370
+ if (state.pi) {
371
+ scheduleIdleTimeout(name, state, state.pi);
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Get the age of a recent failure in seconds, or null if no recent failure.
378
+ */
379
+ export function getFailureAgeSeconds(state: LazyExtensionsState, name: string): number | null {
380
+ const failedAt = state.failureTracker.get(name);
381
+ if (!failedAt) return null;
382
+ const ageMs = Date.now() - failedAt;
383
+ if (ageMs > FAILURE_BACKOFF_MS) {
384
+ state.failureTracker.delete(name);
385
+ return null;
386
+ }
387
+ return Math.round(ageMs / 1000);
388
+ }
389
+
390
+ /**
391
+ * Schedule an idle timeout for a lazy extension.
392
+ * When the timeout fires, the extension is "soft unloaded" — its tools
393
+ * are removed from the active set and the extension is marked as idle.
394
+ *
395
+ * Note: Full unloading (removing event handlers) is not possible with the
396
+ * current ExtensionAPI. We can only deactivate the tools.
397
+ */
398
+ function scheduleIdleTimeout(name: string, state: LazyExtensionsState, pi: ExtensionAPI): void {
399
+ const extState = state.extensions.get(name);
400
+ if (!extState) return;
401
+
402
+ // Clear any existing timer
403
+ if (extState.idleTimer) {
404
+ clearTimeout(extState.idleTimer);
405
+ }
406
+
407
+ const lifecycle = extState.config.lifecycle ?? "lazy";
408
+ if (lifecycle === "keep-alive") return; // Never idle-unload keep-alive extensions
409
+
410
+ const idleMinutes = state.manifest.settings?.idleTimeout ?? 10;
411
+ if (idleMinutes === 0) return; // 0 = never idle-unload
412
+
413
+ extState.idleTimer = setTimeout(() => {
414
+ if (!extState.loaded) return;
415
+
416
+ // Check if recently used
417
+ if (extState.lastUsed) {
418
+ const idleMs = Date.now() - extState.lastUsed;
419
+ if (idleMs < idleMinutes * 60 * 1000) {
420
+ // Not idle yet, reschedule
421
+ scheduleIdleTimeout(name, state, pi);
422
+ return;
423
+ }
424
+ }
425
+
426
+ // "Soft unload" — deactivate this extension's tools
427
+ idleUnloadExtension(name, state, pi);
428
+ }, idleMinutes * 60 * 1000);
429
+ }
430
+
431
+ /**
432
+ * Soft-unload an extension by deactivating its tools.
433
+ *
434
+ * Full unloading (removing event handlers) isn't possible with the current
435
+ * ExtensionAPI. We remove the extension's tools from the active set and
436
+ * mark it as unloaded. A subsequent activation restores tools to the
437
+ * active set without re-calling factory(pi) (avoiding double handlers).
438
+ */
439
+ function idleUnloadExtension(name: string, state: LazyExtensionsState, pi: ExtensionAPI): void {
440
+ const extState = state.extensions.get(name);
441
+ if (!extState || !extState.loaded) return;
442
+
443
+ // Remove tools from the active set so the LLM can no longer call them.
444
+ // Keep extState.registeredTools intact so reactivation can restore them
445
+ // without re-calling factory(pi) (which would double-register event handlers).
446
+ const activeTools = pi.getActiveTools();
447
+ const remaining = activeTools.filter(t => !extState.registeredTools.includes(t));
448
+ pi.setActiveTools(remaining);
449
+
450
+ extState.loaded = false;
451
+ extState.lastActivated = undefined;
452
+ extState.idleTimer = undefined;
453
+ }
454
+
455
+ /**
456
+ * Clear all idle timers (for shutdown).
457
+ */
458
+ export function clearAllTimers(state: LazyExtensionsState): void {
459
+ for (const [, extState] of state.extensions) {
460
+ if (extState.idleTimer) {
461
+ clearTimeout(extState.idleTimer);
462
+ extState.idleTimer = undefined;
463
+ }
464
+ }
465
+ }
466
+
package/types.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Types for the pi-lazy-extensions package.
3
+ */
4
+
5
+ /** Lifecycle mode for a lazy extension. */
6
+ export type ExtensionLifecycle = "lazy" | "eager" | "keep-alive";
7
+
8
+ /** A single extension entry in the lazy manifest. */
9
+ export interface LazyExtensionConfig {
10
+ /** Human-readable name for the extension. */
11
+ name: string;
12
+
13
+ /**
14
+ * Path to the extension's entry point (relative to manifest or absolute).
15
+ * Supports .ts, .js files, or directories with index.ts.
16
+ */
17
+ path: string;
18
+
19
+ /**
20
+ * Lifecycle mode:
21
+ * - "lazy" (default): only loaded when explicitly activated or when a tool calls it
22
+ * - "eager": loaded immediately at session_start
23
+ * - "keep-alive": loaded eagerly and never shut down
24
+ */
25
+ lifecycle?: ExtensionLifecycle;
26
+
27
+ /** Optional description exposed in the proxy tool for search/discovery. */
28
+ description?: string;
29
+
30
+ /**
31
+ * Optional summary of what tools this extension registers.
32
+ * Used for the proxy tool description when the extension is not yet loaded.
33
+ */
34
+ toolSummary?: string[];
35
+
36
+ /**
37
+ * Optional tags for search/filter.
38
+ */
39
+ tags?: string[];
40
+ }
41
+
42
+ /** The full manifest structure. */
43
+ export interface LazyExtensionsManifest {
44
+ /** Version of the manifest format. Currently 1. */
45
+ version: 1;
46
+
47
+ /** Extension entries. */
48
+ extensions: LazyExtensionConfig[];
49
+
50
+ /**
51
+ * Optional settings.
52
+ */
53
+ settings?: {
54
+ /** Whether to disable the proxy tool entirely (only use direct registration). Default: false. */
55
+ disableProxyTool?: boolean;
56
+
57
+ /** Idle timeout in minutes before unloading "lazy" extensions. Default: 10. 0 = never. */
58
+ idleTimeout?: number;
59
+
60
+ /** Comma-separated string of extension names to always load eagerly, overriding their lifecycle. */
61
+ eagerOverrides?: string;
62
+ };
63
+ }
64
+
65
+ /** Runtime state for a loaded extension. */
66
+ export interface LoadedExtensionState {
67
+ /** The config entry from the manifest. */
68
+ config: LazyExtensionConfig;
69
+
70
+ /** Whether the extension has been loaded into the runtime. */
71
+ loaded: boolean;
72
+
73
+ /** Whether the factory has been called at least once (prevents double event handlers). */
74
+ factoryCalled: boolean;
75
+
76
+ /** Timestamp when the extension was last activated. */
77
+ lastActivated?: number;
78
+
79
+ /** Timestamp when the extension was last used (tool call, command, etc.). */
80
+ lastUsed?: number;
81
+
82
+ /** Error message if loading failed. */
83
+ error?: string;
84
+
85
+ /** Names of tools registered by this extension (populated after load). */
86
+ registeredTools: string[];
87
+
88
+ /** Names of commands registered by this extension (populated after load). */
89
+ registeredCommands: string[];
90
+
91
+ /**
92
+ * Shortcuts registered by this extension (populated after load).
93
+ * Tracked for informational purposes — shortcuts cannot be deactivated
94
+ * via setActiveTools and will persist after idle-unload.
95
+ */
96
+ registeredShortcuts: string[];
97
+
98
+ /**
99
+ * Flags registered by this extension (populated after load).
100
+ * Tracked for informational purposes — flags cannot be deactivated
101
+ * and will persist after idle-unload.
102
+ */
103
+ registeredFlags: string[];
104
+
105
+ /**
106
+ * Message renderer customTypes registered by this extension (populated after load).
107
+ * Tracked for informational purposes — renderers cannot be deactivated
108
+ * and will persist after idle-unload.
109
+ */
110
+ registeredRenderers: string[];
111
+
112
+ /** Timer for idle unloading, if applicable. */
113
+ idleTimer?: ReturnType<typeof setTimeout>;
114
+
115
+ /** In-flight activation promise to deduplicate concurrent activations. */
116
+ activationPromise?: Promise<ActivationResult>;
117
+ }
118
+
119
+ /** The overall runtime state for the lazy extensions proxy. */
120
+ export interface LazyExtensionsState {
121
+ /** Map of extension name -> runtime state. */
122
+ extensions: Map<string, LoadedExtensionState>;
123
+
124
+ /** The parsed manifest. */
125
+ manifest: LazyExtensionsManifest;
126
+
127
+ /** Absolute path to the manifest file. */
128
+ manifestPath: string;
129
+
130
+ /** Base directory for resolving relative paths. */
131
+ baseDir: string;
132
+
133
+ /** Tracks recent activation failures to enforce backoff. */
134
+ failureTracker: Map<string, number>;
135
+
136
+ /**
137
+ * Reference to the shared ExtensionAPI, set after buildState().
138
+ * Used by touchExtension() to reschedule idle timers without
139
+ * threading pi through every call site.
140
+ */
141
+ pi?: any;
142
+
143
+ /**
144
+ * Serialization lock for concurrent activations. Only one factory(pi)
145
+ * runs at a time to prevent method-wrapping races and tool-diff
146
+ * misattribution between concurrent activations of different extensions.
147
+ */
148
+ activationLock?: Promise<void>;
149
+ }
150
+
151
+ /** Result of activating an extension. */
152
+ export interface ActivationResult {
153
+ success: boolean;
154
+ name: string;
155
+ tools?: string[];
156
+ commands?: string[];
157
+ /** Shortcuts registered (informational — cannot be deactivated). */
158
+ shortcuts?: string[];
159
+ /** Flags registered (informational — cannot be deactivated). */
160
+ flags?: string[];
161
+ /** Message renderer customTypes registered (informational — cannot be deactivated). */
162
+ renderers?: string[];
163
+ error?: string;
164
+ /**
165
+ * Tool names that were silently skipped due to "first registration wins".
166
+ * The factory called registerTool for these names, but another extension
167
+ * had already registered them.
168
+ */
169
+ duplicateTools?: string[];
170
+ /**
171
+ * True if the extension registered a session_start handler during factory(pi).
172
+ * Since the session has already started, this handler will not fire until
173
+ * the next session/reload, which may cause initialization issues.
174
+ */
175
+ sessionStartWarning?: boolean;
176
+ }