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
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
/helpand autocomplete with the right names and descriptions. The expensive imports run only when you actually invoke a command. - Single-edit configuration. One
lazy: truefield on the package object in your existingsettings.json. Everything else — entry path, package version, command names, prefixes — is auto-derived. - Self-healing cache. Detects upstream package updates via
package.jsonversion + 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 installThen 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 thelazy.jsonpath. Default:~/.pi/agent/lazy.json.PI_CODING_AGENT_DIR— pi's standard agent-directory variable; the loader uses it to locatesettings.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
At startup the loader reads
lazy.jsonand a sidecar cache of each target's known command list. It registers stub commands with the right names and descriptions so they appear in/helpand command autocomplete. The target extension is not imported.On first invocation of any owned command, the stub handler dynamically imports the target. A fake
ExtensionAPIcaptures every registration the extension makes (commands, tools, event handlers, flags, shortcuts, message renderers, providers). After the factory returns, captured registrations are replayed onto the realExtensionAPI— overwriting the stub with the real handler — and the original invocation is forwarded.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 viapi.sendUserMessage. The cache is refreshed automatically so the new command will have a stub on the next startup.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./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 viabefore_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.mdfor 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 --helpafter install runs without stubs. The warmup is scheduled viasetImmediate, but--helpexits before the event loop drains. Subsequent interactive starts populate the cache; from then on every invocation (including--help) gets the fast path. Runningpi -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
virtualModulesfor resolution; this loader imports@mariozechner/jitifrom its ownnode_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
ExtensionAPIcaptures during the factory call, then switches to live mode after replay. This means if the loaded extension stashes thepireference in a closure and registers a tool later (e.g. inside itssession_starthandler), 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>.jsonto see what was discovered. jitiis used for module loading because pi extensions ship as.ts— the same loader pi itself uses internally (dist/core/extensions/loader.jsin 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:
- Run
piin a fresh terminal. - Type one of your lazy-target's command-name prefixes, press Tab. Stubs should appear in autocomplete.
- 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.
- 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.