pi-lazy-extensions 0.1.9 → 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 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:** Once an extension's factory runs, its event handlers are permanent. Idle unloading only removes tools from the active set.
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:** If a lazy extension registers a tool with the same name as an already-registered tool, pi's "first registration wins" policy silently skips it. The `ext` tool displays a warning when this is detected during activation.
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 `@earendil-works/pi-ai/oauth`)
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.9",
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": ">=0.74.0"
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.86.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.86.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
- import { fileURLToPath } from "node:url";
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
- * Build the jiti alias map that mirrors pi's own extension loader.
256
- *
257
- * Pi bundles core packages (typebox, @earendil-works/pi-ai, etc.) and resolves
258
- * them via aliases when loading extensions with jiti. Without these aliases,
259
- * extensions that `import { Type } from "typebox"` or import from
260
- * `@earendil-works/pi-coding-agent` will fail with module-not-found errors.
261
- *
262
- * We resolve the same packages via import.meta.resolve() which works
263
- * because pi-lazy-extensions itself depends on @earendil-works/pi-coding-agent
264
- * (the peer dependency is installed), and typebox is bundled by pi.
265
- */
266
- let _jitiAliases: Record<string, string> | undefined;
267
-
268
- function buildJitiAliases(): Record<string, string> {
269
- if (_jitiAliases) return _jitiAliases;
270
-
271
- const aliases: Record<string, string> = {};
272
-
273
- // Core pi packages that extensions commonly import
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
- * Reset cached jiti aliases (for testing or after module changes).
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 is configured with the same alias map that pi's own extension loader
329
- * uses, so extensions can import from `typebox`, `@earendil-works/pi-coding-agent`,
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
- alias: buildJitiAliases(),
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