pi-lazy-extensions 0.1.11 → 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.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/package.json +6 -6
  3. package/registry.ts +33 -39
package/README.md CHANGED
@@ -15,13 +15,20 @@ _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
+
18
25
  ## Pi 0.99 compatibility (0.1.11)
19
26
 
20
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.
21
28
 
22
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.
23
30
 
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.
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.
25
32
 
26
33
  ## The Problem
27
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lazy-extensions",
3
- "version": "0.1.11",
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",
@@ -38,7 +38,7 @@
38
38
  "typebox": "*"
39
39
  },
40
40
  "scripts": {
41
- "test:host": "node scripts/pi99-host.mjs",
41
+ "test:host": "node scripts/pi1-host.mjs",
42
42
  "test": "vitest run",
43
43
  "test:watch": "vitest",
44
44
  "test:coverage": "vitest run --coverage",
@@ -53,10 +53,10 @@
53
53
  "knip": "6.14.1",
54
54
  "typescript": "6.0.3",
55
55
  "vitest": "4.1.7",
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",
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
60
  "typebox": "1.3.27"
61
61
  },
62
62
  "overrides": {
package/registry.ts CHANGED
@@ -2,12 +2,14 @@
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";
@@ -157,38 +159,38 @@ async function performActivation(
157
159
  const definitions = new Map<string, Parameters<ExtensionAPI["registerTool"]>[0]>();
158
160
  const interceptedEvents: string[] = [];
159
161
 
160
- const origRegisterShortcut = (pi as any).registerShortcut?.bind(pi);
161
- const origRegisterFlag = (pi as any).registerFlag?.bind(pi);
162
- const origRegisterMessageRenderer = (pi as any).registerMessageRenderer?.bind(pi);
163
- const origRegisterTool = pi.registerTool.bind(pi);
164
- 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;
165
167
 
166
168
  (pi as any).registerShortcut = (shortcut: string, options: any) => {
167
169
  interceptedShortcuts.push(shortcut);
168
- origRegisterShortcut?.(shortcut, options);
170
+ origRegisterShortcut?.call(pi, shortcut, options);
169
171
  };
170
172
  (pi as any).registerFlag = (flagName: string, options: any) => {
171
173
  interceptedFlags.push(flagName);
172
- origRegisterFlag?.(flagName, options);
174
+ origRegisterFlag?.call(pi, flagName, options);
173
175
  };
174
176
  (pi as any).registerMessageRenderer = (customType: string, renderer: any) => {
175
177
  interceptedRenderers.push(customType);
176
- origRegisterMessageRenderer?.(customType, renderer);
178
+ origRegisterMessageRenderer?.call(pi, customType, renderer);
177
179
  };
178
180
  pi.registerTool = (tool: any) => {
179
181
  interceptedTools.push(tool.name);
180
- // Lazy factories share our API owner. In 0.99 re-registering would replace
182
+ // Lazy factories share our API owner. In 1.0 re-registering would replace
181
183
  // that owner's tool, not silently lose a collision; preserve existing tools.
182
184
  if (toolsBefore.has(tool.name)) return;
183
185
  definitions.set(tool.name, tool);
184
- origRegisterTool(tool);
186
+ origRegisterTool.call(pi, tool);
185
187
  };
186
188
  // 0.86.0: pi.on returns an unsubscribe function and its event name is a
187
189
  // closed overload set, so the generic string-typed proxy is asserted to
188
190
  // ExtensionAPI["on"]. The `never` cast lets the bound overload set resolve.
189
191
  pi.on = ((event: string, handler: any) => {
190
192
  interceptedEvents.push(event);
191
- return origOn(event as never, handler);
193
+ return origOn.call(pi, event as never, handler);
192
194
  }) as ExtensionAPI["on"];
193
195
 
194
196
  const extPath = resolveExtensionPath(extState.config.path, state.baseDir);
@@ -299,8 +301,8 @@ export function resetJitiAliases(): void { hostModules = undefined; }
299
301
  /**
300
302
  * Load an extension module and extract its default factory function.
301
303
  *
302
- * Tries jiti first (for TypeScript support and module alias resolution),
303
- * 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.
304
306
  *
305
307
  * Jiti receives the same live module objects as Pi's own extension loader,
306
308
  * so lazy imports preserve class identity in both SDK and bundled CLI hosts.
@@ -308,30 +310,22 @@ export function resetJitiAliases(): void { hostModules = undefined; }
308
310
  async function loadExtensionFactory(
309
311
  extPath: string,
310
312
  ): Promise<((pi: ExtensionAPI) => void | Promise<void>) | undefined> {
311
- // Attempt 1: jiti — handles .ts and provides SDK-compatible module aliases
312
- try {
313
- const { createJiti } = await import("jiti");
314
- const jiti = createJiti(import.meta.url, {
315
- moduleCache: false,
316
- virtualModules: getHostModules(),
317
- tryNative: false,
318
- });
319
- const mod = await jiti.import(extPath, { default: true });
320
- if (typeof mod === "function") {
321
- return mod as (pi: ExtensionAPI) => void | Promise<void>;
322
- }
323
- if (typeof (mod as any)?.default === "function") {
324
- return (mod as any).default as (pi: ExtensionAPI) => void | Promise<void>;
325
- }
326
- return undefined;
327
- } catch {
328
- // jiti unavailable or failed — fall through to raw import
329
- }
330
-
331
- // Attempt 2: raw import() — works for .js files, no TypeScript support
332
- const mod = await import(extPath);
333
- if (typeof mod.default === "function") {
334
- 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>;
335
329
  }
336
330
  return undefined;
337
331
  }
@@ -426,7 +420,7 @@ function idleUnloadExtension(name: string, state: LazyExtensionsState, pi: Exten
426
420
  const activeTools = pi.getActiveTools();
427
421
  extState.activeBeforeUnload = activeTools.filter(t => extState.registeredTools.includes(t));
428
422
  const remaining = activeTools.filter(t => !extState.registeredTools.includes(t));
429
- // Removing an active name does not withdraw codemode/deferred tools in 0.99.
423
+ // Removing an active name does not withdraw codemode/deferred tools in 1.0.
430
424
  // Hidden exposure also removes them from ctx.tools / native nested dispatch.
431
425
  for (const definition of extState.toolDefinitions ?? []) {
432
426
  if (definition.exposure === "codemode" || definition.exposure === "deferred") {