---
slug: "pi-lazy-loader"
source_type: "readme"
source_url: "https://cdn.jsdelivr.net/gh/vxio/pi-lazy-loader@main/README.md"
repo: "https://github.com/vxio/pi-lazy-loader"
source_file: "README.md"
branch: "main"
---
# pi-lazy-loader

Lazy-load [pi-coding-agent](https://pi.dev) 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

```bash
pi install npm:pi-lazy-loader
```

## Configuration

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

```jsonc
{
  "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)

```bash
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:

```jsonc
{
  "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`:

```jsonc
// ~/.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:

```bash
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](#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`](https://github.com/vxio/pi-lazy-loader/blob/HEAD/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).

```bash
# 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
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`](https://github.com/vxio/pi-lazy-loader/blob/HEAD/FUTURE.md) for planned features and known limitations
ranked by usefulness.

## Contributing

Issues and pull requests welcome. For local development:

```bash
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`](https://github.com/vxio/pi-lazy-loader/blob/HEAD/docs/RELEASING.md).

## License

MIT — see [`LICENSE`](https://github.com/vxio/pi-lazy-loader/tree/HEAD/LICENSE).
