@rahularya01/pi-lazy
v0.2.8
Published
LazyVim-style extension manager for Pi Coding Agent — load packages after start or on demand (cmd/tools/keywords).
Maintainers
Readme
@rahularya01/pi-lazy
A LazyVim-style extension manager for Pi Coding Agent.
If you've used LazyVim, the idea will feel familiar: keep every
package installed, but only pay the startup cost for the ones you actually use right away.
Everything else loads the moment you reach for it — a slash command, a tool call, a keyword in
your prompt, a shortcut, or a manual /lazy load.
The result: a faster Pi startup, a shorter tool list for the model to reason about, and every package still just one command or keyword away.
Table of contents
- Requirements
- Install
- First-time setup
- Quick start
- Mental model (LazyVim → Pi)
- Load strategies
- Configuration
- Commands
- LLM tool:
lazy_load - How load works
- Recipes
- Troubleshooting
- Limits (v1)
- Development
- Publish
Requirements
- Pi Coding Agent 0.80+ (peer:
@earendil-works/pi-coding-agent) - Node.js 20+
- Packages you want to defer must already be installed via
pi install/settings.packages
Install
# from npm
pi install npm:@rahularya01/pi-lazy
# from a local checkout
pi install /absolute/path/to/pi-lazy
# from git
pi install git:github.com/Rahularya01/pi-lazyOr add it directly to ~/.pi/agent/settings.json:
{
"packages": ["npm:@rahularya01/pi-lazy"]
}Then restart Pi (or run /reload).
Note: pi-lazy itself is always-on — it has to load at startup so it can manage everything else.
Update it later with:
pi update npm:@rahularya01/pi-lazyFirst-time setup (required for true lazy loading)
Installing pi-lazy alone isn't enough — Pi still eager-loads every package listed as a plain
string in settings.packages. You need to migrate once, then restart. It's a three-step
process:
1. Install pi-lazy and restart
pi install npm:@rahularya01/pi-lazyRestart Pi or run /reload.
2. Seed and review your config
On first run, pi-lazy writes a default catalog to ~/.pi/agent/lazy.json.
Edit that file to match your installed packages — see Configuration below. You can also rewrite it back to the defaults anytime with:
/lazy initOnly list packages you actually have installed — specs for missing packages will show up as errors when you try to load them.
3. Migrate your settings
/lazy migrateThis command:
Backs up
~/.pi/agent/settings.jsontosettings.json.bak.lazy-<timestamp>Rewrites managed (
lazy !== false) packages from a plain string:"npm:pi-web-access"into an object with an empty extension filter:
{ "source": "npm:pi-web-access", "extensions": [] }
The package stays installed — its skills and prompts can still load — but Pi will not execute its extension factory at startup. pi-lazy takes over and loads that factory later, on your terms.
Eager specs ("lazy": false) that were previously filtered empty are restored to plain string
form automatically when it's safe to do so.
4. Restart Pi again
Module-lazy filters only apply on the next process start, so restart one more time. After that, running:
/lazyshould show your managed on-demand packages as pending, not eager. The statusline will show
something like lazy 0↑ 4· 3⚡.
If you skip the migrate step, packages stay eager — Pi loads them normally, and
/lazy loadwill just tell you to migrate and restart.
Quick start
/lazy # status + table
/lazy list # compact states
/lazy load web # force-load a pending package
/web # stub command: loads "web", then re-runs /webA typical setup after migrating looks like this:
- Providers (
cursor,antigravity, …) stay"lazy": false→ available immediately. - Common helpers (
subagents,todo, …) use"lazy": "after-start"→ load right after the UI is up. - Heavy packs (
web,mcp,lens, …) use"lazy": true→ load on first/cmd, tool stub, keyword match, or/lazy load.
Mental model (LazyVim → Pi)
Already know LazyVim? Here's the direct translation:
| LazyVim | pi-lazy |
| -------- | --------- |
| lazy = false | "lazy": false |
| event = "VeryLazy" | "lazy": "after-start" |
| cmd = "Telescope" | "cmd": ["plannotator"] |
| keys | "keys": ["ctrl+…"] |
| first tool use | "tools": ["web_search"] |
| prompt / autocmd triggers | "keywords" / "event" |
| :Lazy load foo | /lazy load foo |
| :Lazy | /lazy / /lazy list |
Load strategies
Every package spec picks one of three strategies via its lazy field:
| lazy value | When it loads | Good for |
| ------------ | ------------- | -------- |
| false | With Pi at startup (not managed by pi-lazy) | Providers, auth, anything you need before the first prompt |
| "after-start" | Shortly after session_start (priority order, lower first) | Common tools you almost always use, but not boot-critical |
| true | On demand only | Heavy / situational packs (web, MCP, lens, plan mode) |
On-demand triggers (lazy: true)
Any of the following can wake up a pending package:
| Trigger | How |
| ------- | --- |
| Manual | /lazy load <name> or LLM tool lazy_load |
| Slash command stub | Spec "cmd": ["web"] → /web loads then re-dispatches /web … |
| Tool stub | Spec "tools": ["web_search"] → first call loads the package; call the real tool on the next turn |
| Keywords | Spec "keywords": ["web search"] → matched (case-insensitive) in the user prompt on before_agent_start |
| Events | Spec "event": ["before_agent_start"] → load whenever that Pi event fires (when auto is on) |
| Shortcuts | Spec "keys": ["…"] → registered shortcut loads the package |
Keyword and event auto-load can be toggled on or off:
/lazy auto off
/lazy auto onIt's on by default ("auto": true in lazy.json).
Dependencies
Packages can depend on each other:
{
"name": "my-pack",
"source": "npm:my-pack",
"lazy": true,
"dependencies": ["context-mode"]
}/lazy load my-pack loads context-mode first. If a dependency fails, the parent load fails with
a clear error. Dependency cycles are rejected with the complete cycle path, and concurrent
requests for the same package share a single load.
Configuration
Everything lives in one file: ~/.pi/agent/lazy.json
- Auto-created on first run from built-in defaults
- Reset with
/lazy init(backs up the old file, then requires a reload/restart) - Show its path anytime with
/lazy config
Here's a full example (also in examples/lazy.json):
{
"version": 1,
"defaults": { "lazy": true },
"auto": true,
"autoLoadLimit": 1,
"afterStartBatchSize": 1,
"afterStartDelayMs": 0,
"specs": [
{
"name": "cursor",
"source": "npm:@rahularya01/pi-cursor",
"lazy": false,
"description": "Cursor provider — must be eager"
},
{
"name": "subagents",
"source": "npm:pi-subagents",
"lazy": "after-start",
"priority": 10,
"description": "Load after UI is ready"
},
{
"name": "web",
"source": "npm:pi-web-access",
"lazy": true,
"cmd": ["web"],
"tools": ["web_search", "fetch_content", "get_search_content"],
"keywords": ["web search", "search the web", "fetch url", "youtube"],
"description": "On-demand web / fetch / video"
},
{
"name": "lens",
"source": "npm:pi-lens",
"lazy": true,
"cmd": ["lens"],
"tools": ["lens_diagnostics", "symbol_search", "module_report", "lsp_diagnostics"],
"keywords": ["diagnostics", "symbol search", "ast-grep", "lsp"]
}
]
}Top-level fields
| Field | Type | Meaning |
| ----- | ---- | ------- |
| version | 1 | Config schema version |
| defaults.lazy | false | true | "after-start" | Default when a spec omits lazy (default: true) |
| auto | boolean | Enable keyword/event auto-load (default: true) |
| autoLoadLimit | integer | Maximum packages loaded before one agent turn (default: 1) |
| afterStartBatchSize | integer | Packages loaded in one after-start event-loop slice (default: 1) |
| afterStartDelayMs | integer | Delay between after-start slices in milliseconds (default: 0) |
| specs | array | Package load catalog |
Spec fields
| Field | Required | Meaning |
| ----- | -------- | ------- |
| name | yes | Stable id for /lazy load and status tables |
| source | yes | Same form as settings.packages: npm:…, local path, git:…, etc. |
| lazy | no | Load mode (see Load strategies); falls back to defaults.lazy |
| priority | no | after-start order — lower runs first (default 100) |
| cmd | no | Slash-command names to stub (load-then-re-run) |
| tools | no | Tool names to register as load-on-call stubs |
| keys | no | Keyboard shortcuts that load this package |
| event | no | Pi event names that trigger load (e.g. "before_agent_start") |
| keywords | no | Case-insensitive prompt substrings checked on before_agent_start |
| dependencies | no | Other spec names to load first |
| description | no | Human-readable note (shown in your own docs; not required at runtime) |
Choosing what stays eager
Keep lazy: false for:
- Model providers / auth bridges (
pi-cursor,pi-antigravity,pi-grok-cli, …) - Anything you need before the first agent turn
- Packages whose absence would break your default workflow
Prefer after-start for packs you use most sessions but that aren't boot-critical.
Prefer true (on-demand) for large MCP bridges, web fetch, lens/LSP, plan mode, and other
situational tools.
Matching source to installed packages
source must match how the package appears in Pi:
| Install | Spec source |
| ------- | ------------- |
| pi install npm:pi-web-access | "npm:pi-web-access" |
| pi install npm:@rahularya01/pi-cursor | "npm:@rahularya01/pi-cursor" |
| Local path | Absolute or cwd-relative path to the package root |
pi-lazy resolves npm packages from, in order:
~/.pi/agent/npm/node_modules/<name><cwd>/.pi/npm/node_modules/<name>
If the package isn't installed in either location, the load fails with "package not installed".
Stub names must be declared
Command and tool stubs only exist for names listed in the spec. If pi-web-access registers
web_search but your spec omits it from "tools", the model won't see a lazy stub for that tool
until the package is loaded some other way (keyword, /lazy load, etc.).
So declare the commands and tools you actually want to trigger a load:
{
"name": "web",
"source": "npm:pi-web-access",
"lazy": true,
"cmd": ["web"],
"tools": ["web_search", "fetch_content", "get_search_content"]
}Commands
| Command | Action |
| ------- | ------ |
| /lazy or /lazy status | Status summary + full table (state, mode, source, timings) |
| /lazy list | Compact one-line-per-spec table |
| /lazy load <name> | Force-load a pending package (name, or unique source suffix) |
| /lazy migrate | Apply extensions: [] filters + backup settings.json |
| /lazy auto | Show whether keyword/event auto-load is on |
| /lazy auto on\|off | Toggle auto-load and persist to lazy.json |
| /lazy init | Overwrite lazy.json with the built-in default catalog |
| /lazy config | Print the absolute path to lazy.json |
| /lazy profile | Show in-session catalog, resolution, and load timings |
Tab completion is available for subcommands and load <name>.
Statusline
pi-lazy sets a UI status string like:
lazy 2↑ 3· 4⚡| Glyph | Meaning |
| ----- | ------- |
| ↑ | Loaded this session (via lazy) |
| · | Pending (module-lazy ready, not yet loaded) |
| ⚡ | Eager (Pi loaded, or not migrated yet) |
| ✗ | Error on last load attempt |
List columns
pending web on-demand npm:pi-web-access
loaded subagents after-start npm:pi-subagents 42ms
eager cursor eager npm:@rahularya01/pi-cursor
eager lens on-demand npm:pi-lens (migrate+restart needed)
error mcp on-demand npm:pi-mcp-adapter — package not installed …| State | Meaning |
| ----- | ------- |
| eager | Not managed, or still fully eager in settings (migrate + restart if you expected pending) |
| pending | Filtered with extensions: []; waiting for a trigger |
| loading | Load in progress |
| loaded | Factory ran successfully this session |
| error | Last load failed before activation (message in the row; retry is allowed) |
| poisoned | A factory failed after activation began; restart before retrying |
LLM tool: lazy_load
The model itself can load a deferred package by spec name:
lazy_load({ name: "web" })Use it when a capability is still pending (web, mcp, lens, plannotator, context-mode,
…). Prefer names from /lazy list.
After a successful load, newly registered tools are merged into the active tool set when the
runtime supports getActiveTools / setActiveTools. Stub tools still tell the model to call
the real tool again on the next turn after the first activation.
How load works
When a trigger fires, pi-lazy runs through these steps:
- Resolve the package under Pi's npm (or local) install tree
- Read
package.json→pi.extensions(same rules as Pi's own loader) - Import the factory (
import()for JS; jiti for TypeScript — from the Pi install) - Call
factory(hostPi)so tools, commands, and handlers attach to the live runtime - Replay
session_start(and best-effortresources_discover) handlers registered during that late load - Additively
setActiveToolsfor any tools registered during the load
Dependencies are loaded depth-first before the requested package.
Recipes
Faster startup with a heavy stack
- Put providers on
"lazy": false - Put always-useful orchestration on
"lazy": "after-start"with priorities10,20, … - Put web / MCP / lens / plan on
"lazy": truewithcmd+tools+ a fewkeywords /lazy migrate→ restart → confirm with/lazy list
Force-load before a big task
/lazy load lens
/lazy load context-modeOr ask the agent to call lazy_load for those names.
Temporarily disable keyword auto-load
/lazy auto offManual /lazy load and stub /cmd / tools still work. Turn it back on with /lazy auto on.
Add a new package later
pi install npm:some-package- Add a spec to
~/.pi/agent/lazy.json /lazy migrate(picks up the new managed source)- Restart Pi
/lazy load some-nameor use its stubs
Changes to package specs, stubs, shortcuts, or event triggers take effect after /reload or a
process restart. /lazy auto on|off is the only configuration change applied immediately.
Undo migrate for one package
Edit settings.json: change that entry back to a plain string source (or remove
"extensions": []), keep "lazy": false in lazy.json, then restart. Or restore from the
settings.json.bak.lazy-* backup.
Troubleshooting
| Symptom | What to do |
| ------- | ---------- |
| Everything still eager after install | Run /lazy migrate, then fully restart Pi (not only /reload if filters were just written) |
| /lazy load X → "not module-lazy ready" | Migrate + restart; the package is still a plain string in settings.packages |
| /lazy load X → "package not installed" | pi install that source; confirm it exists under ~/.pi/agent/npm/node_modules/… |
| /lazy load X → "unknown spec" | Add it to lazy.json (name + source); /lazy list shows known names |
| Stub /web does nothing useful | Ensure "cmd": ["web"] on the spec, package is pending, and migrate was applied |
| Model never sees stub tools | List them under "tools"; after first stub call, have it invoke the real tool next turn |
| Keywords never fire | /lazy auto on; keywords are case-insensitive substrings of the user prompt |
| TS extension fails to import | Pi (or its deps) must provide jiti; JS/dist packages load via native import |
| Broken settings after migrate | Restore ~/.pi/agent/settings.json.bak.lazy-<timestamp> |
| Want a clean slate mid-session | No unload in v1 — use /reload or restart Pi |
Startup tip: if managed packages aren't module-lazy yet, pi-lazy notifies you once:
pi-lazy: run /lazy migrate then restart for true module-lazyLimits (v1)
- No mid-session unload — loaded factories stay until
/reload/ process restart - A factory that fails after registering runtime state is marked poisoned and cannot be retried until restart
- Boot-critical packages should stay
"lazy": false - Stub
cmd/toolsnames must be declared in the spec - Skills / prompts stay eager by default — only extensions are deferred via
extensions: [] - Git-sourced packages are not fully resolved in v1 unless you point
sourceat a local path - Without migrate, managed specs remain eager and on-demand load asks you to migrate
Development
Node 22+ required for the test suite — the
@earendil-works/pi-coding-agentdev dependency bundles anundiciversion that needs Node ≥22.19. The published package itself still supports Node 20+ at runtime; only runningnpm testlocally needs the newer Node.
git clone https://github.com/Rahularya01/pi-lazy
cd pi-lazy
npm install
npm test
npm run test:smoke
# run an isolated startup benchmark; never modifies ~/.pi/agent
BENCH_RUNS=3 node scripts/bench-startup.mjs
# load from local path
pi install "$PWD"Layout:
| Path | Role |
| ---- | ---- |
| src/index.ts | Extension entry — commands, stubs, events, lazy_load, profiling |
| src/config.ts | lazy.json load/save + default catalog |
| src/migrate.ts | settings.packages → extensions: [] |
| src/resolve.ts | Package root + extension entry resolution |
| src/loader.ts | Dynamic import / cached jiti setup + factory invoke |
| dist/index.js | Compiled production extension entry |
| src/types.ts | Spec / config types |
| test/unit.test.ts | Hermetic loader, lifecycle, migration, and validation regression tests |
| examples/lazy.json | Example catalog |
Publish
CI runs the hermetic test suite, typecheck, build, production dependency audit, and package-content verification on Node 20, 22, and 24.
Releases publish to npm automatically when a version tag is pushed:
# 1. Bump version in package.json + CHANGELOG.md, commit to main
# 2. Tag must match package.json (e.g. 0.1.1 → v0.1.1)
git tag v0.1.1
git push origin main --tagsGitHub Actions (.github/workflows/publish.yml) will:
- Install deps and run
npm run check - Assert the tag matches
package.jsonversion npm publish --access public --provenance
Required secret: repo NPM_TOKEN (npm automation/granular access token with publish rights
for @rahularya01).
Manual publish (fallback):
npm login
npm publish --access publicThen reinstall in Pi:
pi install npm:@rahularya01/pi-lazy