omp packages

pi-lazy-extensions0.1.15

Lazy-load pi extensions on demand via a ToolSearch-style proxy tool

GitHub Actions Published 3 days ago MIT ~1.4K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

๐Ÿฆฅ pi-lazy-extensions

Lazy-load pi extensions on demand

ToolSearch-style proxy tool โ€” extensions only load when you need them.

pi extension license



Pi 1.0 compatibility (0.1.12)

Tested against Pi 1.0.0 with exact SDK development pins and wildcard host peers. 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.

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.

Pi 0.99 compatibility (0.1.11)

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.

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.

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.

The Problem

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.

The Solution

pi-lazy-extensions registers a single ext proxy tool. The LLM discovers and activates extensions on demand, just like Anthropic's ToolSearch for MCP tools. Extensions stay unloaded until needed.

Startup: only eager extensions load
    โ”‚
    โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ext tool   โ”‚  โ† always available
โ”‚  (pi-lazy)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚
       โ”‚ LLM calls ext({ activate: "todo" })
       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  todo ext   โ”‚  โ† loaded on demand
โ”‚  (lazy)     โ”‚     tools appear instantly
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Install

pi install npm:pi-lazy-extensions

Setup

Create a lazy-extensions.json manifest in your project root or .pi/ directory:

{
  "version": 1,
  "extensions": [
    {
      "name": "todo",
      "path": "~/.pi/agent/extensions/todo.ts",
      "lifecycle": "lazy",
      "description": "Task management - create, list, and track todos",
      "toolSummary": ["todo_list", "todo_add", "todo_complete"],
      "tags": ["productivity", "tasks"]
    },
    {
      "name": "snake",
      "path": "~/.pi/agent/extensions/snake.ts",
      "lifecycle": "lazy",
      "description": "Snake game while you wait",
      "tags": ["fun"]
    },
    {
      "name": "git-checkpoint",
      "path": "~/.pi/agent/extensions/git-checkpoint.ts",
      "lifecycle": "eager",
      "description": "Auto git stash/restore on each turn"
    }
  ],
  "settings": {
    "idleTimeout": 10,
    "eagerOverrides": ""
  }
}

Manifest Location

The manifest is searched in this order:

  1. LAZY_EXTENSIONS_CONFIG environment variable (explicit path)
  2. <cwd>/.pi/lazy-extensions.json (project .pi dir)
  3. ~/.pi/agent/lazy-extensions.json (global)
  4. <cwd>/lazy-extensions.json (project root)

Extension Config

Field Type Description
name string Unique identifier for the extension
path string Path to the extension's entry point (.ts or .js)
lifecycle "lazy" | "eager" | "keep-alive" Default: "lazy". Eager loads at startup, keep-alive never unloads
description string? What the extension does (shown in search results)
toolSummary string[]? Names of tools this extension registers (for discovery before load)
tags string[]? Search/filter tags

Settings

Setting Type Default Description
disableProxyTool boolean false If true, don't register the ext proxy tool
idleTimeout number 10 Minutes before unloading idle lazy extensions (0 = never)
eagerOverrides string "" Comma-separated extension names to force eager loading

Usage

Via the ext tool (LLM calls)

ext({})                                    โ†’ Status: list all extensions
ext({ search: "todo" })                   โ†’ Search extensions matching "todo"
ext({ activate: "todo" })                 โ†’ Load and activate the "todo" extension
ext({ tools: "todo" })                    โ†’ List tools registered by "todo"

After ext({ activate: "todo" }), the todo extension's tools become directly available to the LLM.

Via the /ext command (user calls)

/ext                           โ†’ Show status
/ext activate todo             โ†’ Activate an extension
/ext search productivity       โ†’ Search extensions
/ext tools todo                โ†’ List extension tools

How It Works

  1. At startup, pi-lazy-extensions reads the manifest and registers the ext proxy tool
  2. Eager and keep-alive extensions are loaded immediately during session_start
  3. Lazy extensions stay unloaded โ€” their metadata is available for search/discovery
  4. When the LLM (or user) calls ext({ activate: "name" }), the extension is dynamically loaded via jiti (with the same module alias map that pi's own loader uses, so import { Type } from "typebox" and import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" work correctly), and its factory function is called with the shared ExtensionAPI
  5. New tools registered by the activated extension appear immediately โ€” no /reload needed
  6. After an idle timeout, lazy extensions are "soft unloaded" (their tools are deactivated)

Limitations

  • 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.
  • 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.
  • 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.
  • /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.
  • 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.
  • 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.

Example: Converting an Existing Extension

Before (always loaded):

~/.pi/agent/extensions/my-heavy-ext.ts

After (lazy loaded):

  1. Move or keep the extension in its existing path
  2. Add to lazy-extensions.json:
{
  "version": 1,
  "extensions": [
    {
      "name": "my-heavy-ext",
      "path": "~/.pi/agent/extensions/my-heavy-ext.ts",
      "lifecycle": "lazy",
      "description": "My heavy extension with 5 tools",
      "toolSummary": ["heavy_tool_1", "heavy_tool_2", "heavy_tool_3", "heavy_tool_4", "heavy_tool_5"]
    }
  ]
}
  1. Rename or remove the original from auto-discovery (add a .bak suffix or move it out of ~/.pi/agent/extensions/) so it isn't loaded eagerly by pi itself

Motivation

This is an experiment. If the pattern proves useful, the goal is to propose a pi.loadExtension(path) method to the pi SDK that handles proper sourceInfo attribution, deduplication, and full lifecycle management. This package serves as a working prototype to validate the approach.

Module Resolution

Lazy extensions are loaded via jiti โ€” the same TypeScript/ESM transpiler that pi uses for its own extension loader. The jiti instance is configured with the same alias map that pi builds internally, which resolves:

  • typebox (and typebox/compile, typebox/value)
  • @earendil-works/pi-coding-agent
  • @earendil-works/pi-agent-core
  • @earendil-works/pi-tui
  • @earendil-works/pi-ai (mapped to the host compat entrypoint), /compat, /oauth, and /providers/all
  • @sinclair/typebox (legacy alias)

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.

License

MIT