pi-lazy-extensions 0.1.10 → 0.1.11
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/README.md +11 -3
- package/index.ts +1 -1
- package/package.json +14 -7
- package/registry.ts +55 -70
- package/types.ts +8 -0
package/README.md
CHANGED
|
@@ -15,6 +15,14 @@ _ToolSearch-style proxy tool — extensions only load when you need them._
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## Pi 0.99 compatibility (0.1.11)
|
|
19
|
+
|
|
20
|
+
Tested with Pi **0.99.0**. Host-provided Pi packages and TypeBox are peers (`*`), not bundled runtime dependencies; development uses exact Pi 0.99.0 pins and host-compatible TypeBox where needed.
|
|
21
|
+
|
|
22
|
+
Lazy modules reuse Pi's exact extension-loader virtual modules (SDK and bundled CLI), including import-only ESM/compat exports; they never import a second physical copy of the host runtime. Idle unload withdraws `codemode`/`deferred` tools with native `hidden` exposure; reactivation restores their original exposure without rerunning factories.
|
|
23
|
+
|
|
24
|
+
Run `bun run test:host` for the offline real-host load, native codemode/nested-call, module-identity and reload checks. Set `PI99_HOST_PACKAGE` to an installed Pi package directory to test that host explicitly; add `PI99_HOST_ENTRY=bundle` to check the bundled CLI runtime's constructors.
|
|
25
|
+
|
|
18
26
|
## The Problem
|
|
19
27
|
|
|
20
28
|
Every extension in `~/.pi/agent/extensions/` loads at startup. If you have many extensions, they all register their tools, commands, and event handlers immediately — even if you rarely use them. This clutters the tool list and wastes resources.
|
|
@@ -154,12 +162,12 @@ After `ext({ activate: "todo" })`, the `todo` extension's tools become directly
|
|
|
154
162
|
|
|
155
163
|
### Limitations
|
|
156
164
|
|
|
157
|
-
- **No full unloading:**
|
|
165
|
+
- **No full unloading:** Idle unloading does not unsubscribe the extension's handlers. Direct tools leave the active set; codemode/deferred tools are re-registered as hidden so native nested dispatch cannot reach them.
|
|
158
166
|
- **Shortcuts, flags, and message renderers persist:** These registrations have no deactivate/remove API in the current ExtensionAPI. They remain active even after idle-unload. The `ext` status display shows counts of these for awareness.
|
|
159
167
|
- **`sourceInfo` attribution:** Tools registered by lazy-loaded extensions will show the `pi-lazy-extensions` extension's `sourceInfo`, not the original extension's. This is because `pi.registerTool()` stamps each tool with the `sourceInfo` of the *extension that made the call* — and since the lazy extension's factory receives the same `ExtensionAPI` as `pi-lazy-extensions`, all registered tools are attributed to `pi-lazy-extensions`. There is no SDK API to override this.
|
|
160
168
|
- **`/ext` command vs `ext` tool:** The `/ext` command uses `ctx.ui.notify()` for output, which may truncate large results. For rich output (search results, detailed status), prefer the `ext` tool interface which renders properly in the TUI.
|
|
161
169
|
- **`session_start` handlers never fire for the current session:** When a lazy extension is activated mid-session, any `session_start` handlers it registers will not fire until the next session or `/reload`. Extensions that depend on `session_start` for initialization (reading config, setting up state) may not work correctly when lazy-loaded. The `ext` tool displays a warning when this is detected.
|
|
162
|
-
- **Duplicate tool names:**
|
|
170
|
+
- **Duplicate tool names:** The loader skips names already registered before activation and reports a warning. This guard matters in Pi 0.99: tools sharing our API owner would otherwise replace each other on re-registration.
|
|
163
171
|
|
|
164
172
|
---
|
|
165
173
|
|
|
@@ -204,7 +212,7 @@ Lazy extensions are loaded via `jiti` — the same TypeScript/ESM transpiler tha
|
|
|
204
212
|
- `@earendil-works/pi-coding-agent`
|
|
205
213
|
- `@earendil-works/pi-agent-core`
|
|
206
214
|
- `@earendil-works/pi-tui`
|
|
207
|
-
- `@earendil-works/pi-ai` (and
|
|
215
|
+
- `@earendil-works/pi-ai` (mapped to the host compat entrypoint), `/compat`, `/oauth`, and `/providers/all`
|
|
208
216
|
- `@sinclair/typebox` (legacy alias)
|
|
209
217
|
|
|
210
218
|
This ensures lazy extensions can use the same imports as normally-loaded extensions. If jiti is unavailable (e.g. stripped from the runtime), the loader falls back to raw `import()`, which only works for `.js` files without SDK imports.
|
package/index.ts
CHANGED
|
@@ -53,7 +53,7 @@ export default function lazyExtensions(pi: ExtensionAPI) {
|
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
collisionChecked = true;
|
|
56
|
-
if (existingTools.some(t => t.name === "ext")) {
|
|
56
|
+
if (existingTools.some(t => t.name === "ext" && t.description !== PROXY_TOOL_DESCRIPTION)) {
|
|
57
57
|
console.error(
|
|
58
58
|
"pi-lazy-extensions: another extension already registered a tool named 'ext'. " +
|
|
59
59
|
"The proxy tool will not be available. Use the /ext command instead, " +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lazy-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Lazy-load pi extensions on demand via a ToolSearch-style proxy tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,13 +28,17 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"jiti": "2.7.0"
|
|
32
|
-
"typebox": "1.1.38"
|
|
31
|
+
"jiti": "2.7.0"
|
|
33
32
|
},
|
|
34
33
|
"peerDependencies": {
|
|
35
|
-
"@earendil-works/pi-coding-agent": "
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-ai": "*",
|
|
36
|
+
"@earendil-works/pi-agent-core": "*",
|
|
37
|
+
"@earendil-works/pi-tui": "*",
|
|
38
|
+
"typebox": "*"
|
|
36
39
|
},
|
|
37
40
|
"scripts": {
|
|
41
|
+
"test:host": "node scripts/pi99-host.mjs",
|
|
38
42
|
"test": "vitest run",
|
|
39
43
|
"test:watch": "vitest",
|
|
40
44
|
"test:coverage": "vitest run --coverage",
|
|
@@ -49,13 +53,16 @@
|
|
|
49
53
|
"knip": "6.14.1",
|
|
50
54
|
"typescript": "6.0.3",
|
|
51
55
|
"vitest": "4.1.7",
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.99.0",
|
|
57
|
+
"@earendil-works/pi-ai": "0.99.0",
|
|
58
|
+
"@earendil-works/pi-agent-core": "0.99.0",
|
|
59
|
+
"@earendil-works/pi-tui": "0.99.0",
|
|
60
|
+
"typebox": "1.3.27"
|
|
53
61
|
},
|
|
54
62
|
"overrides": {
|
|
55
63
|
"brace-expansion": "5.0.6",
|
|
56
64
|
"fast-xml-builder": "1.2.0",
|
|
57
65
|
"protobufjs": "8.4.0",
|
|
58
|
-
"ws": "8.20.1"
|
|
59
|
-
"@earendil-works/pi-coding-agent": "0.87.0"
|
|
66
|
+
"ws": "8.20.1"
|
|
60
67
|
}
|
|
61
68
|
}
|
package/registry.ts
CHANGED
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { ActivationResult, LazyExtensionsState } from "./types.js";
|
|
13
13
|
import { resolveExtensionPath } from "./config.js";
|
|
14
|
-
|
|
14
|
+
// These imports are mapped by Pi to its live modules (including bundled CLI).
|
|
15
|
+
import * as hostCodingAgent from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import * as hostAgentCore from "@earendil-works/pi-agent-core";
|
|
17
|
+
import * as hostAi from "@earendil-works/pi-ai/compat";
|
|
18
|
+
import * as hostOauth from "@earendil-works/pi-ai/oauth";
|
|
19
|
+
import * as hostProviders from "@earendil-works/pi-ai/providers/all";
|
|
20
|
+
import * as hostTui from "@earendil-works/pi-tui";
|
|
21
|
+
import * as hostTypebox from "typebox";
|
|
22
|
+
import * as hostCompile from "typebox/compile";
|
|
23
|
+
import * as hostValue from "typebox/value";
|
|
15
24
|
|
|
16
25
|
const FAILURE_BACKOFF_MS = 60 * 1000;
|
|
17
26
|
|
|
@@ -47,8 +56,11 @@ export async function activateExtension(
|
|
|
47
56
|
// factory(pi) call.
|
|
48
57
|
if (extState.factoryCalled) {
|
|
49
58
|
const currentActive = new Set(pi.getActiveTools());
|
|
59
|
+
for (const definition of extState.toolDefinitions ?? []) {
|
|
60
|
+
if (definition.exposure === "codemode" || definition.exposure === "deferred") pi.registerTool(definition);
|
|
61
|
+
}
|
|
50
62
|
const restored: string[] = [];
|
|
51
|
-
for (const toolName of extState.registeredTools) {
|
|
63
|
+
for (const toolName of extState.activeBeforeUnload ?? extState.registeredTools) {
|
|
52
64
|
if (!currentActive.has(toolName)) {
|
|
53
65
|
restored.push(toolName);
|
|
54
66
|
}
|
|
@@ -142,6 +154,7 @@ async function performActivation(
|
|
|
142
154
|
const interceptedFlags: string[] = [];
|
|
143
155
|
const interceptedRenderers: string[] = [];
|
|
144
156
|
const interceptedTools: string[] = [];
|
|
157
|
+
const definitions = new Map<string, Parameters<ExtensionAPI["registerTool"]>[0]>();
|
|
145
158
|
const interceptedEvents: string[] = [];
|
|
146
159
|
|
|
147
160
|
const origRegisterShortcut = (pi as any).registerShortcut?.bind(pi);
|
|
@@ -164,6 +177,10 @@ async function performActivation(
|
|
|
164
177
|
};
|
|
165
178
|
pi.registerTool = (tool: any) => {
|
|
166
179
|
interceptedTools.push(tool.name);
|
|
180
|
+
// Lazy factories share our API owner. In 0.99 re-registering would replace
|
|
181
|
+
// that owner's tool, not silently lose a collision; preserve existing tools.
|
|
182
|
+
if (toolsBefore.has(tool.name)) return;
|
|
183
|
+
definitions.set(tool.name, tool);
|
|
167
184
|
origRegisterTool(tool);
|
|
168
185
|
};
|
|
169
186
|
// 0.86.0: pi.on returns an unsubscribe function and its event name is a
|
|
@@ -194,6 +211,7 @@ async function performActivation(
|
|
|
194
211
|
const toolsAfter = pi.getAllTools().map((t: any) => t.name as string);
|
|
195
212
|
const newTools = toolsAfter.filter(t => !toolsBefore.has(t));
|
|
196
213
|
extState.registeredTools = newTools;
|
|
214
|
+
extState.toolDefinitions = newTools.flatMap(name => definitions.has(name) ? [definitions.get(name)!] : []);
|
|
197
215
|
|
|
198
216
|
// Diff commands the same way
|
|
199
217
|
const commandsAfter = pi.getCommands().map((c: any) => c.name as string);
|
|
@@ -251,73 +269,32 @@ async function performActivation(
|
|
|
251
269
|
}
|
|
252
270
|
}
|
|
253
271
|
|
|
254
|
-
/**
|
|
255
|
-
*
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const piPackages = [
|
|
275
|
-
"@earendil-works/pi-coding-agent",
|
|
276
|
-
"@earendil-works/pi-agent-core",
|
|
277
|
-
"@earendil-works/pi-tui",
|
|
278
|
-
"@earendil-works/pi-ai",
|
|
279
|
-
"@earendil-works/pi-ai/oauth",
|
|
280
|
-
] as const;
|
|
281
|
-
|
|
282
|
-
for (const pkg of piPackages) {
|
|
283
|
-
try {
|
|
284
|
-
aliases[pkg] = fileURLToPath(import.meta.resolve(pkg));
|
|
285
|
-
} catch {
|
|
286
|
-
// Package not available in this environment — skip
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// typebox and its subpath exports (most extensions use `import { Type } from "typebox"`)
|
|
291
|
-
const typeboxSpecs = ["typebox", "typebox/compile", "typebox/value"] as const;
|
|
292
|
-
for (const spec of typeboxSpecs) {
|
|
293
|
-
try {
|
|
294
|
-
aliases[spec] = fileURLToPath(import.meta.resolve(spec));
|
|
295
|
-
} catch {
|
|
296
|
-
// typebox not available — skip
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Alias legacy @sinclair/typebox to the same entries (some extensions still use it)
|
|
301
|
-
if (aliases["typebox"]) {
|
|
302
|
-
aliases["@sinclair/typebox"] = aliases["typebox"];
|
|
303
|
-
}
|
|
304
|
-
if (aliases["typebox/compile"]) {
|
|
305
|
-
aliases["@sinclair/typebox/compile"] = aliases["typebox/compile"];
|
|
306
|
-
}
|
|
307
|
-
if (aliases["typebox/value"]) {
|
|
308
|
-
aliases["@sinclair/typebox/value"] = aliases["typebox/value"];
|
|
272
|
+
/** Reuse the exact host objects, not a second installation beside a bundled
|
|
273
|
+
* CLI. Virtual modules also work when managed host peers are not on disk. */
|
|
274
|
+
let hostModules: Record<string, unknown> | undefined;
|
|
275
|
+
function getHostModules(): Record<string, unknown> {
|
|
276
|
+
if (hostModules) return hostModules;
|
|
277
|
+
hostModules = {
|
|
278
|
+
"@earendil-works/pi-coding-agent": hostCodingAgent,
|
|
279
|
+
"@earendil-works/pi-agent-core": hostAgentCore,
|
|
280
|
+
"@earendil-works/pi-ai": hostAi,
|
|
281
|
+
"@earendil-works/pi-ai/compat": hostAi,
|
|
282
|
+
"@earendil-works/pi-ai/oauth": hostOauth,
|
|
283
|
+
"@earendil-works/pi-ai/providers/all": hostProviders,
|
|
284
|
+
"@earendil-works/pi-tui": hostTui,
|
|
285
|
+
typebox: hostTypebox, "typebox/compile": hostCompile, "typebox/value": hostValue,
|
|
286
|
+
"@sinclair/typebox": hostTypebox,
|
|
287
|
+
"@sinclair/typebox/compile": hostCompile,
|
|
288
|
+
"@sinclair/typebox/value": hostValue,
|
|
289
|
+
};
|
|
290
|
+
for (const [key, value] of Object.entries(hostModules)) {
|
|
291
|
+
if (key.startsWith("@earendil-works/pi-")) hostModules[key.replace("@earendil-works/", "@mariozechner/")] = value;
|
|
309
292
|
}
|
|
310
|
-
|
|
311
|
-
_jitiAliases = aliases;
|
|
312
|
-
return aliases;
|
|
293
|
+
return hostModules;
|
|
313
294
|
}
|
|
314
295
|
|
|
315
|
-
/**
|
|
316
|
-
|
|
317
|
-
*/
|
|
318
|
-
export function resetJitiAliases(): void {
|
|
319
|
-
_jitiAliases = undefined;
|
|
320
|
-
}
|
|
296
|
+
/** Retained test/public reset name; the cache now holds host virtual modules. */
|
|
297
|
+
export function resetJitiAliases(): void { hostModules = undefined; }
|
|
321
298
|
|
|
322
299
|
/**
|
|
323
300
|
* Load an extension module and extract its default factory function.
|
|
@@ -325,9 +302,8 @@ export function resetJitiAliases(): void {
|
|
|
325
302
|
* Tries jiti first (for TypeScript support and module alias resolution),
|
|
326
303
|
* then falls back to raw import() for .js files or when jiti is unavailable.
|
|
327
304
|
*
|
|
328
|
-
* Jiti
|
|
329
|
-
*
|
|
330
|
-
* etc. just like they would in a normally-loaded extension.
|
|
305
|
+
* Jiti receives the same live module objects as Pi's own extension loader,
|
|
306
|
+
* so lazy imports preserve class identity in both SDK and bundled CLI hosts.
|
|
331
307
|
*/
|
|
332
308
|
async function loadExtensionFactory(
|
|
333
309
|
extPath: string,
|
|
@@ -337,7 +313,8 @@ async function loadExtensionFactory(
|
|
|
337
313
|
const { createJiti } = await import("jiti");
|
|
338
314
|
const jiti = createJiti(import.meta.url, {
|
|
339
315
|
moduleCache: false,
|
|
340
|
-
|
|
316
|
+
virtualModules: getHostModules(),
|
|
317
|
+
tryNative: false,
|
|
341
318
|
});
|
|
342
319
|
const mod = await jiti.import(extPath, { default: true });
|
|
343
320
|
if (typeof mod === "function") {
|
|
@@ -447,7 +424,15 @@ function idleUnloadExtension(name: string, state: LazyExtensionsState, pi: Exten
|
|
|
447
424
|
// Keep extState.registeredTools intact so reactivation can restore them
|
|
448
425
|
// without re-calling factory(pi) (which would double-register event handlers).
|
|
449
426
|
const activeTools = pi.getActiveTools();
|
|
427
|
+
extState.activeBeforeUnload = activeTools.filter(t => extState.registeredTools.includes(t));
|
|
450
428
|
const remaining = activeTools.filter(t => !extState.registeredTools.includes(t));
|
|
429
|
+
// Removing an active name does not withdraw codemode/deferred tools in 0.99.
|
|
430
|
+
// Hidden exposure also removes them from ctx.tools / native nested dispatch.
|
|
431
|
+
for (const definition of extState.toolDefinitions ?? []) {
|
|
432
|
+
if (definition.exposure === "codemode" || definition.exposure === "deferred") {
|
|
433
|
+
pi.registerTool({ ...definition, exposure: "hidden" });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
451
436
|
pi.setActiveTools(remaining);
|
|
452
437
|
|
|
453
438
|
extState.loaded = false;
|
package/types.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Types for the pi-lazy-extensions package.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
5
7
|
/** Lifecycle mode for a lazy extension. */
|
|
6
8
|
export type ExtensionLifecycle = "lazy" | "eager" | "keep-alive";
|
|
7
9
|
|
|
@@ -85,6 +87,12 @@ export interface LoadedExtensionState {
|
|
|
85
87
|
/** Names of tools registered by this extension (populated after load). */
|
|
86
88
|
registeredTools: string[];
|
|
87
89
|
|
|
90
|
+
/** Original definitions, retained to withdraw callable tools with native hidden exposure. */
|
|
91
|
+
toolDefinitions?: ToolDefinition[];
|
|
92
|
+
|
|
93
|
+
/** Model declarations selected immediately before idle withdrawal. */
|
|
94
|
+
activeBeforeUnload?: string[];
|
|
95
|
+
|
|
88
96
|
/** Names of commands registered by this extension (populated after load). */
|
|
89
97
|
registeredCommands: string[];
|
|
90
98
|
|