pi-lazy-loader

Lazy-load pi-coding-agent extensions on first slash-command use. Keeps slash commands discoverable while deferring heavy module loads until activation.

Package details

extension

Install pi-lazy-loader from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-lazy-loader
Package
pi-lazy-loader
Version
0.1.0
Published
Apr 27, 2026
Downloads
119/mo · 119/wk
Author
vxio
License
MIT
Types
extension
Size
59.7 KB
Dependencies
1 dependency · 0 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-lazy-loader

Lazy-load pi-coding-agent extensions on first slash-command use.

Heavy extensions can add hundreds of milliseconds to every pi invocation — even pi --help and one-shot pi -p "...". For extensions whose UX is entirely command-driven (you only invoke them via /some-command), this work is wasted on most invocations. pi-lazy-loader defers it: slash commands remain visible in /help and autocomplete, but the expensive imports happen only when you actually use one.

The trade-off is simple: cold starts get faster, but the first invocation of one of the deferred extension's slash commands in a given pi process pays its load cost. Every invocation after that, in the same process and in every future process, is free. The size of the win depends entirely on which extensions you defer — see "Will this help on my machine?" after the Configuration section for a script that measures the answer for you.

Features

  • Stubs at startup, real load on demand. Slash commands stay in /help and autocomplete with the right names and descriptions. The expensive imports run only when you actually invoke a command.
  • Single-edit configuration. One lazy: true field on the package object in your existing settings.json. Everything else — entry path, package version, command names, prefixes — is auto-derived.
  • Self-healing cache. Detects upstream package updates via package.json version + entry mtime; refreshes on next invocation without manual steps.
  • Input-event fallback for commands added by upstream that aren't in the cache yet — they still route to the right target and trigger the load, then update the cache automatically.
  • Bundled benchmark and verification scripts. Measure per-package cold-start cost on your machine, flag safe-to-defer extensions, run 13 black-box correctness checks against a real pi install.
  • Atomic cache writes so concurrent pi processes don't corrupt each other's state.

Installation

Requirements: pi-coding-agent ≥ 0.70.

pi install npm:pi-lazy-loader

Note: the package isn't on npm yet. Until then, install from source:

cd ~/code  # or wherever you keep checkouts
git clone https://github.com/vxio/pi-lazy-loader && cd pi-lazy-loader
npm install

Then register the local path in ~/.pi/agent/settings.json:

{ "packages": ["/Users/you/code/pi-lazy-loader"] }

Configuration

For each npm-installed extension you want to defer, edit the package's entry in ~/.pi/agent/settings.json:

{
  "packages": [
    {
      "source": "npm:my-extension",
      "extensions": [],
      "lazy": true
    }
  ]
}

Two fields, both meaningful:

  • extensions: [] — pi reads this. It tells pi to skip loading this package's extension code at startup.
  • lazy: true — pi-lazy-loader reads this. Pi ignores unknown fields, so it doesn't conflict with anything.

Everything else — the entry file path, the package version for staleness tracking, the slash-command name prefixes used to route new upstream commands — is auto-derived from source and the package's own package.json. You don't specify any of it.

If your settings already have the package as a bare string ("npm:my-extension"), expand it to the object form above.

Populate the cache (optional but recommended)

pi -p "/lazy-rebuild"

This does one upfront discovery pass: imports each target with a fake API, captures its slash-command list, writes the cache, then exits. From then on, every cold start has the stubs visible in /help and autocomplete immediately.

You can skip this — the loader populates the cache automatically the first time you invoke any matching command in an interactive session — but doing it explicitly means autocomplete works on the very first run.

Multiple targets

Add "lazy": true to as many package entries as you like:

{
  "packages": [
    { "source": "npm:foo", "extensions": [], "lazy": true },
    { "source": "npm:bar", "extensions": [], "lazy": true },
    { "source": "npm:other-thing-you-still-want-eager" }
  ]
}

Loose extension files (advanced)

Extension files dropped into ~/.pi/agent/extensions/ aren't part of any package, so they can't be configured inline. For these, use ~/.pi/agent/lazy.json:

// ~/.pi/agent/lazy.json
{
  "targets": [
    {
      "name": "my-loose-ext",
      "entry": "/absolute/path/to/extension.ts",
      "prefixes": ["my-loose-ext"]
    }
  ]
}
Field Required Description
name yes Stable identifier; used as the cache filename.
entry yes Absolute path to the extension's entry file.
prefixes yes here Explicit slash-command prefixes claimed by this target. Required for loose-file mode because there's no package source we can derive from. Match rule: cmd === prefix OR cmd starts with prefix + "-".
package no npm package name for version-based staleness checks (only meaningful if entry happens to live in a package).
packageJson no Explicit path to the package.json for version checks.

Inline (settings.json) and lazy.json can coexist. If the same target name appears in both, inline wins and a warning is logged.

Environment variables

  • PI_LAZY_CONFIG — override the lazy.json path. Default: ~/.pi/agent/lazy.json.
  • PI_CODING_AGENT_DIR — pi's standard agent-directory variable; the loader uses it to locate settings.json, lazy.json, and the cache directory.

Will this help on my machine?

Clone the repo and run the bundled benchmark to see how much each of your configured pi packages contributes to startup time and which ones are safe to lazy-load:

git clone https://github.com/vxio/pi-lazy-loader && cd pi-lazy-loader
bash scripts/bench.sh candidates

The script toggles each package off in turn, measures the per-package cost in milliseconds, and tags each as good / risky / unsuitable based on what the package's source code registers (commands only vs. LLM-callable tools vs. per-turn before_agent_start hooks). If nothing in your setup costs more than ~30ms with a good verdict, this loader probably isn't worth your time.

See Verifying and measuring for the other modes (save, compare, plain bench) and for the correctness suite.

How it works

  1. At startup the loader reads lazy.json and a sidecar cache of each target's known command list. It registers stub commands with the right names and descriptions so they appear in /help and command autocomplete. The target extension is not imported.

  2. On first invocation of any owned command, the stub handler dynamically imports the target. A fake ExtensionAPI captures every registration the extension makes (commands, tools, event handlers, flags, shortcuts, message renderers, providers). After the factory returns, captured registrations are replayed onto the real ExtensionAPI — overwriting the stub with the real handler — and the original invocation is forwarded.

  3. For commands not yet in the cache (e.g. a new command added by an upstream update), an input-event handler matches /<prefix>-* patterns, triggers the same lazy-load path, and re-dispatches the command via pi.sendUserMessage. The cache is refreshed automatically so the new command will have a stub on the next startup.

  4. Staleness detection uses the entry file's mtime plus the npm package version (when configured). Stale caches still produce stubs (fast startup); a fresh capture happens on the next invocation.

  5. /lazy-rebuild [target] forces a full re-discovery on demand.

Commands

Command Description
/lazy-rebuild Force re-discovery of all lazy targets' commands. Use after upgrading a target package.
/lazy-rebuild <name> Refresh a single target.

Caveats

These are inherent to lazy-loading; know what you're trading away.

  • Past events are missed. A target's session_start, before_agent_start, tool_call, etc. handlers don't fire for events that occurred before first activation. After activation, future events flow normally. If your target relies on startup-time side effects (e.g. installing a TUI panel unconditionally, or modifying every system prompt via before_agent_start), don't lazy-load it.
  • Tools registered by the target are invisible to the LLM until activation. The LLM's system-prompt tool list is built at startup. If you want the LLM to autonomously call a target's tool without a user slash command, this loader's current design isn't right for that target. See FUTURE.md for the planned eager-tool-stub mode.
  • First invocation per process pays the full load cost. ~300–400ms for larger extensions. The win is amortized across the many invocations that never touch the target.
  • First-ever pi --help after install runs without stubs. The warmup is scheduled via setImmediate, but --help exits before the event loop drains. Subsequent interactive starts populate the cache; from then on every invocation (including --help) gets the fast path. Running pi -p "/lazy-rebuild" after install gives you the fast path immediately.
  • Cache staleness window. After upgrading a target package, the first invocation refreshes the cache. New commands work via the input-event fallback even before the cache catches up.
  • Entry paths must be absolute. Relative paths are rejected at config validation. Symlinks are followed normally.
  • Bun single-binary pi is untested. Pi has a Bun-bundled distribution that uses virtualModules for resolution; this loader imports @mariozechner/jiti from its own node_modules, which should resolve under that distribution but has not been verified. Standard Node.js pi installs are fully tested.
  • Skills, themes, prompts. These are loaded by pi's resource loader from the package, not the extension code. Filtering with extensions: [] keeps them eagerly available, which is usually what you want.

Troubleshooting

"Command /foo no longer registered by ..." — the cached command list is out of date and the upstream extension dropped that name. Run /lazy-rebuild <target>.

"target ... entry not found" — for inline targets, the package isn't installed where pi-lazy-loader looked. For lazy.json targets, the path is wrong. For npm packages, run npm root -g then point entry at <root>/<package>/<entry-file> (or use the inline form so it's auto-derived).

"package ... has `lazy: true` but is missing `extensions: []`" — pi will load the extension eagerly because it doesn't see the filter. Add "extensions": [] to the same package object in settings.json. Both fields go in the same object: one for pi, one for pi-lazy-loader.

"could not locate install path for ..." — the package source resolves to a location that doesn't exist on disk. Confirm the package is installed (npm list -g <name>); if your global npm prefix is non-standard, set PATH so npm root -g works at startup.

"prefix ... claimed by both" — two targets list the same prefix. Pick one and remove it from the other; the second target is disabled to avoid ambiguity.

Slash command runs the eager extension instead of the lazy stub — pi still has the target loaded eagerly. Apply the extensions: [] filter (see Step 3) or move the loose extension file out of ~/.pi/agent/extensions/.

pi install fails or npm ci errors during contribution@mariozechner/pi-coding-agent is a devDependency used only by npm run check. Runtime resolution doesn't need it; the package will still load and run if dev dependencies are skipped.

Architecture notes

  • The fake ExtensionAPI captures during the factory call, then switches to live mode after replay. This means if the loaded extension stashes the pi reference in a closure and registers a tool later (e.g. inside its session_start handler), that registration forwards correctly to real pi instead of vanishing into the dead capture maps.
  • Failed loads do not poison the load-state cache. Subsequent invocations retry instead of seeing the same rejected promise.
  • Cache writes are atomic (write to temp file, then rename) so concurrent pi processes can't corrupt each other's state.
  • The cache file format is intentionally simple JSON. Inspect it at ~/.pi/agent/.lazy-cache/<name>.json to see what was discovered.
  • jiti is used for module loading because pi extensions ship as .ts — the same loader pi itself uses internally (dist/core/extensions/loader.js in pi's source).

Verifying and measuring

The repo ships two helper scripts. They aren't included in the npm tarball — clone the repo to use them.

scripts/bench.sh — measure your own savings

Useful before you adopt the loader (see what each package costs and whether it's safe to defer), and after (confirm you actually got the speedup the README promises).

# Quick cold-start measurement
bash scripts/bench.sh                       # 10 timed runs, prints stats

# Find packages worth lazy-loading on YOUR machine
bash scripts/bench.sh candidates            # toggles each package, ranks by cost,
                                            # flags suitability based on source signals

# Before/after comparison around a configuration change
bash scripts/bench.sh save before           # snapshot
# ... edit settings.json to add lazy: true ...
bash scripts/bench.sh save after            # second snapshot
bash scripts/bench.sh compare before after  # diff

The candidates mode answers "which extensions in my setup are even worth deferring?" — it temporarily toggles each package off, re-bench marks, and combines that with a static scan of the package source for signals that would make lazy-loading unsafe (LLM-callable tools, before_agent_start hooks, etc.). Settings.json is restored on exit even if you Ctrl+C.

compare snapshots are JSON files under ${TMPDIR:-/tmp}/pi-bench-*.json so you can diff them by hand or export to other tools.

scripts/verify.sh — black-box correctness check

13 checks against a real pi install: cold-start latency, stub registration via RPC, stub-handler invocation, input-event fallback, concurrent-process safety. Requires at least one configured target.

bash scripts/verify.sh

Manual interactive smoke test

Neither script covers the interactive TUI path. To check that:

  1. Run pi in a fresh terminal.
  2. Type one of your lazy-target's command-name prefixes, press Tab. Stubs should appear in autocomplete.
  3. Run one of those commands. Watch for a brief "Loading…" indicator in the footer (typically a few hundred ms), then the target's UI should open normally.
  4. Run a second target command. Should be instant (load cached for the rest of the session).

Future improvements

See FUTURE.md for planned features and known limitations ranked by usefulness.

Contributing

Issues and pull requests welcome. For local development:

git clone https://github.com/vxio/pi-lazy-loader
cd pi-lazy-loader
npm install
npm run check                 # tsc --noEmit
bash scripts/verify.sh        # against a real pi install

Release process for maintainers: see docs/RELEASING.md.

License

MIT — see LICENSE.