@zhuyanqi/pi-lazy0.6.4
LazyVim-style extension manager for Pi Coding Agent — community-maintained continuation of @rahularya01/pi-lazy (original author: Rahul Arya) with compat layers, doctor, and maintainer patches.
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.
Reports 0
No reports yet.
Readme
pi-lazy (maintained continuation)
A LazyVim-style extension manager for Pi Coding Agent.
This is not the original project. pi-lazy was written by Rahul Arya and published as
@rahularya01/pi-lazy. The maintainer of this repository is not the original author — this is a continuation, picking up a project whose upstream stopped at v0.3.1 (commitfd4910a, 2026-08-27) with a growing issue queue. Credit for the design and the original implementation belongs to Rahul Arya. See Why a fork? and CHANGELOG.md for what this continuation adds and how patches flow.
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.
Why a fork?
| Upstream | This fork (manjughosa/pi-lazy) |
|
|---|---|---|
| Last commit | 2026-08-27 (over 2 months stale) | tracked, every release gets a CHANGELOG entry |
| Open issues answered | unmaintained | 11 patches, 2 issues filed, all addressed |
Compatibility with pi-extmgr |
breaks lazy semantics | ensureManagerCompat auto-patches on session_start |
Compatibility with pi-powerline-footer |
clobbers the status line twice | ensureFooterCompat auto-patches the fast-welcome path |
| Doctor / diagnostic | absent | /lazy doctor checks settings, lazy state, extmgr conflicts |
auto: true default |
yes (loaded eagerly on session start) | no (auto: false; opt-in per spec) |
| Typebox collision warning | yes (duplicate Host-provided instance) | peer-dependency only, no collision |
| Built-in default config | ships 5 ghost package entries | trimmed to the 5 that actually exist |
Maintenance policy
- Patches flow in from local use — this fork exists because the maintainer (you) uses it
daily. Every behavior change is made in
src/and rebuilt withnpm run build; the resultingdist/index.jsis committed, and shows up inCHANGELOG.mdunder the next minor version. - One working tree — the live extension is this repository
(
E:/pi-agent/agent/extensions/pi-lazy).src/,dist/, and the git history live side by side, so a change and its commit are never two steps apart. This repo previously had to be kept in sync with a separatepi-lazy-fork/copy; that duplicate is gone. - Backports from upstream — if the original
@rahularya01/pi-lazyrepo gets a new commit, it's cherry-picked into the next0.5.xpatch release. - Releases are tagged — every release is a signed
vX.Y.Zgit tag onmain; install viagit:github.com/manjughosa/pi-lazyif you want pin-to-version behavior, or stay on the localdist/index.jsfor zero-network startup (recommended for performance — see Install). - No silent breaking changes — see the CHANGELOG. Every release notes which upstream version it forked from and what was added/changed/fixed.
Install paths and their trade-offs
source in settings.json |
Network on every Pi start? | Recommended? |
|---|---|---|
E:/pi-agent/agent/extensions/pi-lazy (local dist) |
No | Yes — fastest |
git:github.com/manjughosa/pi-lazy |
Yes (git fetch + checkout) |
only when you need pin-to-version |
npm:@rahularya01/pi-lazy |
Yes (npm registry) | No — that is the stale upstream |
npm:@zhuyanqi/pi-lazy |
Yes (npm registry) | Yes — the published fork, 0.6.1 and newer |
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
Pick the path that matches your performance budget (see Install paths and their trade-offs):
# published fork — the normal install for everyone else
pi install npm:@zhuyanqi/pi-lazy
# local checkout — zero network, fastest startup (when you run it in place)
pi install /absolute/path/to/pi-lazy
# pin-to-version via this fork's git tag
pi install git:github.com/manjughosa/pi-lazy
# upstream npm (stale — use the fork instead)
pi install npm:@rahularya01/pi-lazy
Or add it directly to ~/.pi/agent/settings.json:
{
"packages": ["npm:@zhuyanqi/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:@zhuyanqi/pi-lazy
First-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:@zhuyanqi/pi-lazy
Restart 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.
To have the catalog pick up packages automatically, run:
/lazy init
init merges: every spec already in lazy.json is kept exactly as written, and only packages
listed in settings.json that the catalog does not yet cover gain a new entry. It never replaces
your file with the built-in defaults. init then runs /lazy migrate for you, so the merged
catalog is actually applied in the same step.
To only merge, without touching settings.json, use /lazy scan.
init also folds anything it newly discovered into every saved snapshot — see
Snapshots and new packages.
Newly discovered packages are added as lazy: false (eager) with a keywords entry derived from
the package name, and local extensions under extensions/ are registered the same way. Discovery
records what is installed; it does not decide when anything loads. Set lazy: true or after-start
by hand to defer a package.
Only 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
If you ran /lazy init in step 2, this is already done — init performs the merge and this
step. Run /lazy migrate on its own only when you changed lazy.json by hand.
/lazy migrate
This 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:
/lazy
should 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 /web
A 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 on
It'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
- Merge new packages from
settings.jsonwith/lazy init(or/lazy scanfor the catalog only) — existing specs are never overwritten - 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,
"afterStartInitialDelayMs": 750,
"afterStartPauseDuringTurn": true,
"afterStartAdaptiveYield": true,
"afterStartPrefetch": true,
"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 | Minimum delay between after-start slices in milliseconds (default: 0) |
afterStartInitialDelayMs |
integer | Delay before the after-start queue starts, in milliseconds (default: 750) |
afterStartPauseDuringTurn |
boolean | Hold the after-start queue while the agent is working (default: true) |
afterStartAdaptiveYield |
boolean | Yield at least as long as the previous slice blocked the event loop, capped at 250 ms (default: true) |
afterStartPrefetch |
boolean | Import the next slice's modules while the current one activates (default: true) |
specs |
array | Package load catalog |
Keeping startup smooth
Loading an extension is mostly synchronous work — transforming TypeScript and evaluating a module graph — so it blocks the event loop for as long as it takes, no matter how the queue is sliced. pi-lazy therefore concentrates on when that work happens rather than trying to make it cheaper:
- It doesn't start immediately.
afterStartInitialDelayMskeeps the queue clear of Pi's first paint and of your first keystrokes. - It stops while you're working. With
afterStartPauseDuringTurn, the queue parks atbefore_agent_startand resumes atagent_end/agent_settled, so it never competes with streaming output. - It gives time back.
afterStartAdaptiveYieldsleeps for as long as the previous slice ran (up to 250 ms), so heavy packages yield more than light ones. - It overlaps what it can.
afterStartPrefetchimports the next slice's modules while the current one activates. Factory execution stays strictly serial and inpriorityorder.
If startup still feels slow, the fix is usually the catalog rather than these
knobs: run /lazy profile to see per-package load times, then move the expensive
packages from "after-start" to true and give them cmd / tools / keywords
triggers so they load on first real use instead.
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) |
Local extensions under extensions/
Extensions living in <agent-dir>/extensions/<name>/ are not packages. Pi finds them by walking that
directory and loads whatever each folder's manifest declares, so there is no settings.packages
entry and no extensions filter — which means this extension cannot defer them at all. A spec alone
would be inert.
/lazy init and /lazy scan therefore register each discovered directory as a package (a spec
plus a settings.packages entry), which is the only form that carries a filter and can be deferred.
The package is added eager, matching how Pi was already loading it: discovery must not silently
change load timing. Set lazy: true or after-start on the spec afterwards to actually defer it.
Folders without a usable manifest are skipped rather than guessed at.
Keep pi-lazy itself eager (
lazy: false). It is the thing that defers everything else, so deferring it makes it absent from the session, its filters never get applied, and every package it manages stops loading.
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 set <name> true|false|after-start |
Change one spec's lazy mode in lazy.json, then migrate. Both the name and the mode complete from tab |
/lazy migrate |
Apply [] filters to all four resource types + 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 |
Scan settings.json for packages the catalog misses, add them, then run /lazy migrate and fold the new packages into every saved snapshot |
/lazy scan (alias s) |
Same catalog merge as init, without the migrate step |
/lazy disable <name> |
Remove a package from the loading scheme (catalog spec + settings entry), then migrate. Leaves node_modules alone |
/lazy enable <name> |
Restore a package disabled earlier (spec + settings entry from the newest *.bak.disable-* snapshot), then migrate |
/lazy disable / /lazy enable |
List the full package roster recorded by the last /lazy init |
/lazy rescue |
Hand every package back to native Pi: clear extensions filters and empty the catalog |
/lazy snapshot <name> |
Save the whole loading scheme under a name |
/lazy snapshot |
List saved snapshots (name, spec/package counts, saved-at) |
/lazy delete <name> |
Delete one saved snapshot (bare /lazy delete lists what can be deleted) |
/lazy restore <name> |
Replace the current scheme with a saved snapshot, then migrate |
/lazy doctor |
Diagnose catalog vs settings vs runtime inconsistencies (invalid lazy values, duplicates, un-migrated entries, poisoned loads) |
/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, load <name>, snapshot names after restore / delete, and both arguments of set — package names are read from lazy.json, and the mode list is true / false / after-start.
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-mode
Or ask the agent to call lazy_load for those names.
Temporarily disable keyword auto-load
/lazy auto off
Manual /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/lazy init— adds the spec for you, then migrates (or add the spec to~/.pi/agent/lazy.jsonby hand and run/lazy migrate)- 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.
Switch between whole plugin setups
A snapshot is the entire loading scheme — the catalog, its global switches, and the
settings.json package list with each entry's extensions filter form intact. Storing only
lazy.json cannot reproduce a scheme: the packages array is what Pi registers, and the filter
form there ([] = module-lazy, ["+index.ts"] = force one entrypoint, absent = eager) decides
eager-vs-lazy behaviour no amount of spec editing can reproduce.
/lazy snapshot work # save the current scheme
/lazy snapshot # list what is saved
/lazy restore work # switch to it
Snapshots live in ~/.pi/agent/lazy-snapshots/<name>.json.
restore is a replacement, not a merge. Restoring a scheme that uses 5,6,7,8 over one using
1,2,3,4 ends with exactly 5,6,7,8 — 1,2,3,4 are dropped from both lazy.json and
settings.packages. This happens in three steps, and the first is the one that matters:
settings.packages← the snapshot's captured list (this is what unregisters1,2,3,4)lazy.json← the snapshot's catalog/lazy migrate← turns the snapshot'slazymodes intoextensions: []
Skip step 1 and the old packages stay registered and keep loading, which is why a
catalog-only implementation produced 1,2,3,4,5,6,7,8 instead of 5,6,7,8.
Packages a snapshot does not mention are reported, never deleted from disk. Bring any of them
back with pi install npm:<name>. Both files are backed up (*.bak.restore-<stamp>) before the
first write.
Snapshots and new packages
Because restore replaces settings.packages wholesale, a package installed after a snapshot was
taken is absent from it — and restoring that snapshot silently deregisters the package. Nothing
errors; the extension just stops loading.
/lazy init closes that hole. After the catalog merge and migrate, it brings every saved
snapshot up to the live plugin set — adding whatever spec or package entry each one is missing:
── snapshots ──
updated 3: work (+1 spec, +1 pkg), minimal (+1 spec, +1 pkg), alt (+1 spec, +1 pkg)
The direction is live → snapshots, and additive only. A snapshot never loses anything, and
entries already in it keep their own extensions filter form — that form is the reason the snapshot
exists. It runs after migrate, so what lands in the snapshots is the live entry in its resolved
shape.
So the schemes stay in step on which plugins exist and differ on when each one loads. Note the
direction: running init while a smaller scheme is active does not trim the others, because that
would delete plugins from a snapshot you saved on purpose.
Change one package's mode
/lazy set <name> true|false|after-start
Writes the lazy field of one spec in lazy.json, then runs /lazy migrate so settings.packages
agrees. Every other spec is left alone, and so is defaults.lazy.
/lazy set web after-start # load right after session start
/lazy set web true # back to on-demand
/lazy set web false # always load with Pi
The name resolves like disable / enable do — exact spec name, then source suffix, then a
substring match — with one difference: a substring that fits more than one spec is refused
('pi' is ambiguous — matches: pi-lazy, pi-lsp, …) instead of silently taking the first hit.
set writes state, so guessing the wrong package is not a recoverable typo.
The modes are the three values lazy.json already accepts:
| Mode | Meaning |
|---|---|
true |
on-demand — loads on a cmd / tool / keyword / event trigger, or /lazy load |
after-start |
loads right after session_start, ordered by priority |
false |
always loads with Pi (eager, not managed) |
true / on / lazy and false / off / eager and after-start / afterstart are accepted
spellings; anything else is rejected with the list of valid modes. Setting a spec to the mode it
already has reports already <mode> — nothing to do and writes nothing.
Use /lazy set bare to print every spec with its current mode — handy when you do not remember the
exact name.
When the new mode lands
set updates lazy.json and settings.packages on the spot and keeps /lazy status honest, but the
factories of the current process are not re-negotiated:
| Change | When it takes effect |
|---|---|
true ⇄ after-start |
Next session — the after-start queue is built at session_start |
anything → false |
Restart — eager loading is decided while Pi boots |
false → true / after-start |
Restart — the factory is already registered in this process |
/reload is enough for the settings side, not for the module side. This is the same limit /lazy disable runs into: a loaded extension factory has no inverse operation.
set is not disable. set decides when a catalog member loads; disable decides whether it is
a member at all.
Drop a package from the scheme
/lazy disable <name> # alias: d
Removes the spec from lazy.json and the entry from settings.packages, then runs /lazy migrate.
Named disable rather than remove/uninstall because extensions: [] does not uninstall anything:
Pi's package manager defaults every entry to enabled and applies +/- overrides on top, so an
empty filter list just yields no enabled files while the package stays in node_modules.
This is not a fourth lazy mode. The three modes decide when a catalog member loads; disabling
decides whether it is a member at all. Both halves must go — a spec whose source is no longer
registered is inert, and deleting only the spec would leave it listed as a permanently failing
entry.
The trailing migrate reports nothing to change for the disabled package itself — it is out of
both files, so there is nothing left to translate — and confirms the rest of the catalog did not
drift.
Nothing is reloaded automatically. A loaded extension factory has no inverse operation (its tools,
commands and handlers are already registered), so run Pi's own /reload when you want the change
to take effect.
Put a package back
/lazy enable <name> # alias: en
The mirror image of disable: the spec and the settings entry are restored from the most recent
*.bak.disable-* snapshot that still contains them, so the original lazy / after-start / eager
mode comes back with it. A migrate runs afterwards to translate that mode back into extensions: []
— without it the restored entry would be registered but eager, which is exactly the drift the
two-file design invites.
node_modules is untouched in either direction: pi install npm:<name> remains the way to add or
remove the package itself.
Both verbs accept any package and report the state they find rather than failing:
| Request | Reply |
|---|---|
disable on an already-disabled package |
already out of the loading scheme, nothing to do |
enable on an already-enabled package |
already in the loading scheme [mode], nothing to do |
Running either bare (/lazy enable, /lazy disable) lists the full roster recorded by the last
/lazy init, so the packages offered while typing are the same set the commands accept.
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-lazy
Limits (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 - All four resource types are deferred together.
migratewritesextensions: [],skills: [],prompts: []andthemes: []. Pi reads an absent key as "load everything the package declares", so filtering onlyextensionswould leave the package's skills, prompts and themes live — its tools gone but its skills still in the system prompt. - A package that re-registers its own resources at runtime can still surface them. If a
package's extension calls
resources_discoverto add its skills directory, that runs whenever the extension loads, and no settings filter narrows it. Superpowers does exactly this. - 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/manjughosa/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 → [] filters on extensions / skills / prompts / themes |
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
Published package
This repository is published to npm as @zhuyanqi/pi-lazy:
pi install npm:@zhuyanqi/pi-lazy # what users run
npm view @zhuyanqi/pi-lazy version # 0.6.4 and newer
@manjughosa/pi-lazy was never published — no package exists under that name.
Release flow (0.6.2 onwards)
- Bump
versioninpackage.jsonand add a## X.Y.Z — YYYY-MM-DDsection toCHANGELOG.md. npm run check(typecheck + esbuild bundle), then commit onmain.git tag -a vX.Y.Z -m "..."— the tag must matchpackage.jsonexactly.git push origin main --tags.npm publish --access publicfrom the repository root.
Publishing needs proof of presence: this account runs tfa.mode: auth-and-writes, so a plain
npm publish fails with EOTP. Either pass a code from the authenticator (--otp=<code>), or use a
granular access token generated with Bypass two-factor authentication enabled and hand it to npm
through the environment only:
$env:NODE_AUTH_TOKEN = "<granular token>"
npm publish --registry=https://registry.npmjs.org
The registry's staged publishing (default since 2026-09-03) publishes a public 0.0.0-stage
placeholder the first time a package name is used; the real version appears a few minutes later. A
stage that is never approved stays pending — npx npm@11 stage list shows pending stages, and
npx npm@11 stage approve <stage-id> (2FA required) publishes one. npm CLI below 11.15.0 has no
stage subcommand, so use npx npm@11 for those.
GitHub Actions
.github/workflows/ci.ymlruns typecheck, build and the unit suite on every push..github/workflows/publish.ymlpublishes on av*tag through OIDC trusted publishing — noNPM_TOKENsecret exists to create or rotate.- Bootstrap caveat: a trusted publisher can only be configured on a package that already exists, so the first release of a new name has to be published by hand (0.6.1 and 0.6.2 were). Afterwards, add this repository and workflow under npmjs.com → package → Settings → Trusted publishing.
The upstream
@rahularya01/pi-lazynpm package is not touched; this fork publishes under its own scope.
Backporting from upstream
When @rahularya01/pi-lazy gets a new commit:
git remote add upstream git@github.com:Rahularya01/pi-lazy.git(one-time).git fetch upstream.- Identify the upstream commit range since
fd4910a(the fork base). - Cherry-pick onto
main, runnpm test, then patch-release as0.5.x.
Manual publish (fallback)
npm login
npm publish --access public # add --otp=<code> when the account has 2FA
Then reinstall in Pi:
pi install npm:@zhuyanqi/pi-lazy
Required npm-side setup
- Trusted publisher for
@zhuyanqi/pi-lazy: add this repository pluspublish.ymlunder npmjs.com → package → Settings → Trusted publishing. Until that entry exists, a tag push fails at the publish step and the release has to be published by hand.