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/LICENSE +21 -0
- package/README.md +214 -0
- package/config.ts +152 -0
- package/index.ts +249 -0
- package/package.json +59 -0
- package/proxy-modes.ts +319 -0
- package/registry.ts +466 -0
- package/types.ts +176 -0
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
|
+
}
|