pi-lazy-extensions 0.1.10 → 0.1.12

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,21 @@ _ToolSearch-style proxy tool — extensions only load when you need them._
15
15
 
16
16
  ---
17
17
 
18
+ ## Pi 1.0 compatibility (0.1.12)
19
+
20
+ Tested against Pi **1.0.0** with exact SDK development pins and wildcard host peers.
21
+ Lazy entrypoints now force Jiti transpilation: native ESM must not bypass mapped host constructors. Failed entrypoint evaluation is not retried with native import, and API interception restores exact original methods. Host probes cover TS/ESM identity, callable-only idle withdrawal/reactivation and native nested execution.
22
+
23
+ Run `bun run test:host` for the offline real-host regression. Set `PI1_HOST_PACKAGE` to an installed Pi package directory and `PI1_HOST_ENTRY=bundle` to test its bundled CLI runtime.
24
+
25
+ ## Pi 0.99 compatibility (0.1.11)
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ Run `bun run test:host` for the offline real-host load, native codemode/nested-call, module-identity and reload checks. Set `PI1_HOST_PACKAGE` to an installed Pi package directory to test that host explicitly; add `PI1_HOST_ENTRY=bundle` to check the bundled CLI runtime's constructors.
32
+
18
33
  ## The Problem
19
34
 
20
35
  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 +169,12 @@ After `ext({ activate: "todo" })`, the `todo` extension's tools become directly
154
169
 
155
170
  ### Limitations
156
171
 
157
- - **No full unloading:** Once an extension's factory runs, its event handlers are permanent. Idle unloading only removes tools from the active set.
172
+ - **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
173
  - **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
174
  - **`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
175
  - **`/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
176
  - **`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.
177
+ - **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
178
 
164
179
  ---
165
180
 
@@ -204,7 +219,7 @@ Lazy extensions are loaded via `jiti` — the same TypeScript/ESM transpiler tha
204
219
  - `@earendil-works/pi-coding-agent`
205
220
  - `@earendil-works/pi-agent-core`
206
221
  - `@earendil-works/pi-tui`
207
- - `@earendil-works/pi-ai` (and `@earendil-works/pi-ai/oauth`)
222
+ - `@earendil-works/pi-ai` (mapped to the host compat entrypoint), `/compat`, `/oauth`, and `/providers/all`
208
223
  - `@sinclair/typebox` (legacy alias)
209
224
 
210
225
  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.10",
3
+ "version": "0.1.12",
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/pi1-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.87.0"
56
+ "@earendil-works/pi-coding-agent": "1.0.0",
57
+ "@earendil-works/pi-ai": "1.0.0",
58
+ "@earendil-works/pi-agent-core": "1.0.0",
59
+ "@earendil-works/pi-tui": "1.0.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
@@ -2,16 +2,27 @@
2
2
  * Extension registry - loads and tracks lazy extensions.
3
3
  *
4
4
  * This is the core of pi-lazy-extensions. It handles:
5
- * - Dynamic loading of extensions via jiti (or import()) + factory(pi)
5
+ * - Dynamic loading of extensions via host-mapped jiti + factory(pi)
6
6
  * - Tracking registered tools/commands per extension
7
7
  * - Idle timeout unloading
8
8
  * - Deduplication guards
9
9
  */
10
10
 
11
+ import { readFile } from "node:fs/promises";
12
+ import { fileURLToPath } from "node:url";
11
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
14
  import type { ActivationResult, LazyExtensionsState } from "./types.js";
13
15
  import { resolveExtensionPath } from "./config.js";
14
- import { fileURLToPath } from "node:url";
16
+ // These imports are mapped by Pi to its live modules (including bundled CLI).
17
+ import * as hostCodingAgent from "@earendil-works/pi-coding-agent";
18
+ import * as hostAgentCore from "@earendil-works/pi-agent-core";
19
+ import * as hostAi from "@earendil-works/pi-ai/compat";
20
+ import * as hostOauth from "@earendil-works/pi-ai/oauth";
21
+ import * as hostProviders from "@earendil-works/pi-ai/providers/all";
22
+ import * as hostTui from "@earendil-works/pi-tui";
23
+ import * as hostTypebox from "typebox";
24
+ import * as hostCompile from "typebox/compile";
25
+ import * as hostValue from "typebox/value";
15
26
 
16
27
  const FAILURE_BACKOFF_MS = 60 * 1000;
17
28
 
@@ -47,8 +58,11 @@ export async function activateExtension(
47
58
  // factory(pi) call.
48
59
  if (extState.factoryCalled) {
49
60
  const currentActive = new Set(pi.getActiveTools());
61
+ for (const definition of extState.toolDefinitions ?? []) {
62
+ if (definition.exposure === "codemode" || definition.exposure === "deferred") pi.registerTool(definition);
63
+ }
50
64
  const restored: string[] = [];
51
- for (const toolName of extState.registeredTools) {
65
+ for (const toolName of extState.activeBeforeUnload ?? extState.registeredTools) {
52
66
  if (!currentActive.has(toolName)) {
53
67
  restored.push(toolName);
54
68
  }
@@ -142,36 +156,41 @@ async function performActivation(
142
156
  const interceptedFlags: string[] = [];
143
157
  const interceptedRenderers: string[] = [];
144
158
  const interceptedTools: string[] = [];
159
+ const definitions = new Map<string, Parameters<ExtensionAPI["registerTool"]>[0]>();
145
160
  const interceptedEvents: string[] = [];
146
161
 
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);
162
+ const origRegisterShortcut = (pi as any).registerShortcut;
163
+ const origRegisterFlag = (pi as any).registerFlag;
164
+ const origRegisterMessageRenderer = (pi as any).registerMessageRenderer;
165
+ const origRegisterTool = pi.registerTool;
166
+ const origOn = pi.on;
152
167
 
153
168
  (pi as any).registerShortcut = (shortcut: string, options: any) => {
154
169
  interceptedShortcuts.push(shortcut);
155
- origRegisterShortcut?.(shortcut, options);
170
+ origRegisterShortcut?.call(pi, shortcut, options);
156
171
  };
157
172
  (pi as any).registerFlag = (flagName: string, options: any) => {
158
173
  interceptedFlags.push(flagName);
159
- origRegisterFlag?.(flagName, options);
174
+ origRegisterFlag?.call(pi, flagName, options);
160
175
  };
161
176
  (pi as any).registerMessageRenderer = (customType: string, renderer: any) => {
162
177
  interceptedRenderers.push(customType);
163
- origRegisterMessageRenderer?.(customType, renderer);
178
+ origRegisterMessageRenderer?.call(pi, customType, renderer);
164
179
  };
165
180
  pi.registerTool = (tool: any) => {
166
181
  interceptedTools.push(tool.name);
167
- origRegisterTool(tool);
182
+ // Lazy factories share our API owner. In 1.0 re-registering would replace
183
+ // that owner's tool, not silently lose a collision; preserve existing tools.
184
+ if (toolsBefore.has(tool.name)) return;
185
+ definitions.set(tool.name, tool);
186
+ origRegisterTool.call(pi, tool);
168
187
  };
169
188
  // 0.86.0: pi.on returns an unsubscribe function and its event name is a
170
189
  // closed overload set, so the generic string-typed proxy is asserted to
171
190
  // ExtensionAPI["on"]. The `never` cast lets the bound overload set resolve.
172
191
  pi.on = ((event: string, handler: any) => {
173
192
  interceptedEvents.push(event);
174
- return origOn(event as never, handler);
193
+ return origOn.call(pi, event as never, handler);
175
194
  }) as ExtensionAPI["on"];
176
195
 
177
196
  const extPath = resolveExtensionPath(extState.config.path, state.baseDir);
@@ -194,6 +213,7 @@ async function performActivation(
194
213
  const toolsAfter = pi.getAllTools().map((t: any) => t.name as string);
195
214
  const newTools = toolsAfter.filter(t => !toolsBefore.has(t));
196
215
  extState.registeredTools = newTools;
216
+ extState.toolDefinitions = newTools.flatMap(name => definitions.has(name) ? [definitions.get(name)!] : []);
197
217
 
198
218
  // Diff commands the same way
199
219
  const commandsAfter = pi.getCommands().map((c: any) => c.name as string);
@@ -251,110 +271,61 @@ async function performActivation(
251
271
  }
252
272
  }
253
273
 
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"];
274
+ /** Reuse the exact host objects, not a second installation beside a bundled
275
+ * CLI. Virtual modules also work when managed host peers are not on disk. */
276
+ let hostModules: Record<string, unknown> | undefined;
277
+ function getHostModules(): Record<string, unknown> {
278
+ if (hostModules) return hostModules;
279
+ hostModules = {
280
+ "@earendil-works/pi-coding-agent": hostCodingAgent,
281
+ "@earendil-works/pi-agent-core": hostAgentCore,
282
+ "@earendil-works/pi-ai": hostAi,
283
+ "@earendil-works/pi-ai/compat": hostAi,
284
+ "@earendil-works/pi-ai/oauth": hostOauth,
285
+ "@earendil-works/pi-ai/providers/all": hostProviders,
286
+ "@earendil-works/pi-tui": hostTui,
287
+ typebox: hostTypebox, "typebox/compile": hostCompile, "typebox/value": hostValue,
288
+ "@sinclair/typebox": hostTypebox,
289
+ "@sinclair/typebox/compile": hostCompile,
290
+ "@sinclair/typebox/value": hostValue,
291
+ };
292
+ for (const [key, value] of Object.entries(hostModules)) {
293
+ if (key.startsWith("@earendil-works/pi-")) hostModules[key.replace("@earendil-works/", "@mariozechner/")] = value;
309
294
  }
310
-
311
- _jitiAliases = aliases;
312
- return aliases;
295
+ return hostModules;
313
296
  }
314
297
 
315
- /**
316
- * Reset cached jiti aliases (for testing or after module changes).
317
- */
318
- export function resetJitiAliases(): void {
319
- _jitiAliases = undefined;
320
- }
298
+ /** Retained test/public reset name; the cache now holds host virtual modules. */
299
+ export function resetJitiAliases(): void { hostModules = undefined; }
321
300
 
322
301
  /**
323
302
  * Load an extension module and extract its default factory function.
324
303
  *
325
- * Tries jiti first (for TypeScript support and module alias resolution),
326
- * then falls back to raw import() for .js files or when jiti is unavailable.
304
+ * Jiti is required for both JS and TS. Never retry a failed evaluation with
305
+ * native import: it can execute side effects twice and bypass host mapping.
327
306
  *
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.
307
+ * Jiti receives the same live module objects as Pi's own extension loader,
308
+ * so lazy imports preserve class identity in both SDK and bundled CLI hosts.
331
309
  */
332
310
  async function loadExtensionFactory(
333
311
  extPath: string,
334
312
  ): Promise<((pi: ExtensionAPI) => void | Promise<void>) | undefined> {
335
- // Attempt 1: jiti — handles .ts and provides SDK-compatible module aliases
336
- try {
337
- const { createJiti } = await import("jiti");
338
- const jiti = createJiti(import.meta.url, {
339
- moduleCache: false,
340
- alias: buildJitiAliases(),
341
- });
342
- const mod = await jiti.import(extPath, { default: true });
343
- if (typeof mod === "function") {
344
- return mod as (pi: ExtensionAPI) => void | Promise<void>;
345
- }
346
- if (typeof (mod as any)?.default === "function") {
347
- return (mod as any).default as (pi: ExtensionAPI) => void | Promise<void>;
348
- }
349
- return undefined;
350
- } catch {
351
- // jiti unavailable or failed — fall through to raw import
352
- }
353
-
354
- // Attempt 2: raw import() — works for .js files, no TypeScript support
355
- const mod = await import(extPath);
356
- if (typeof mod.default === "function") {
357
- return mod.default as (pi: ExtensionAPI) => void | Promise<void>;
313
+ const { createJiti } = await import("jiti");
314
+ const jiti = createJiti(import.meta.url, {
315
+ moduleCache: false,
316
+ virtualModules: getHostModules(),
317
+ tryNative: false,
318
+ });
319
+ // Jiti 2.7 treats .mjs (and type:module .js) as native even with
320
+ // tryNative:false. Force the entrypoint through its mapped evaluator so
321
+ // bundled hosts never patch a second SDK and evaluation errors are not retried.
322
+ const filename = fileURLToPath(jiti.esmResolve(extPath));
323
+ const mod = await jiti.evalModule(await readFile(filename, "utf8"), {
324
+ filename, async: true, forceTranspile: true,
325
+ });
326
+ if (typeof mod === "function") return mod as (pi: ExtensionAPI) => void | Promise<void>;
327
+ if (typeof (mod as any)?.default === "function") {
328
+ return (mod as any).default as (pi: ExtensionAPI) => void | Promise<void>;
358
329
  }
359
330
  return undefined;
360
331
  }
@@ -447,7 +418,15 @@ function idleUnloadExtension(name: string, state: LazyExtensionsState, pi: Exten
447
418
  // Keep extState.registeredTools intact so reactivation can restore them
448
419
  // without re-calling factory(pi) (which would double-register event handlers).
449
420
  const activeTools = pi.getActiveTools();
421
+ extState.activeBeforeUnload = activeTools.filter(t => extState.registeredTools.includes(t));
450
422
  const remaining = activeTools.filter(t => !extState.registeredTools.includes(t));
423
+ // Removing an active name does not withdraw codemode/deferred tools in 1.0.
424
+ // Hidden exposure also removes them from ctx.tools / native nested dispatch.
425
+ for (const definition of extState.toolDefinitions ?? []) {
426
+ if (definition.exposure === "codemode" || definition.exposure === "deferred") {
427
+ pi.registerTool({ ...definition, exposure: "hidden" });
428
+ }
429
+ }
451
430
  pi.setActiveTools(remaining);
452
431
 
453
432
  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