The Extension System
Self-extensibility: one loader powers built-ins & users
“~40 lifecycle events; jiti + VIRTUAL_MODULES for the Bun binary”
Deep Dive
Self-extending: one jiti loader drives built-in and user; ~40 lifecycle events
Learning Objectives
After this chapter you will be able to:
- Explain the minimal contract of "extension = default export function"
- Describe how the jiti loader loads TS extensions at runtime with virtual module injection
- Explain how the "built-in as extension" architecture guarantees API consistency
- Understand how ~40 lifecycle events cover the Agent's full lifecycle
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/coding-agent/src/core/extensions/types.ts | 1694 | Full ExtensionAPI type definitions |
packages/coding-agent/src/core/extensions/runner.ts | 1223 | Event dispatch, tool/command/keybinding management |
packages/coding-agent/src/core/extensions/loader.ts | 721 | jiti loading + VIRTUAL_MODULES injection |
Design Motivation: Why "Built-in as Extension"?
Problem: pi has built-in tools (read, edit, bash) and user-defined tools. If the two go through different registration paths, the API splits — built-in tools can do things user tools can't, or vice versa.
Analogy: Like Linux kernel modules — the built-in ext4 and third-party zfs use the same module API. No "privileged modules" vs "commoner modules."
pi's iron law: Built-in features (llama.cpp integration, built-in tools) and user extensions go through the same loader, the same ExtensionAPI. If a built-in feature needs an API, user extensions can use it too.
The Extension Contract: One Default Export Function
// my-extension.ts — this is a complete extension
export default function (pi: ExtensionAPI) {
// Register an LLM-callable tool
pi.tools.defineTool("screenshot", {
description: "Capture the current screen",
parameters: Type.Object({ region: Type.Optional(Type.String()) }),
execute: async ({ region }) => captureScreen(region),
})
// Register a slash command
pi.commands.register("/deploy", async (args) => {
await deployToProd(args)
return "Deployment complete"
})
// Subscribe to lifecycle events
pi.events.on("session_before_compact", async ({ messages, opts }) => {
// Protect critical messages before compaction
opts.keep.push(...pickMustKeep(messages))
})
}
Minimal: No class, no inheritance, no registry. One function, receive the API, do what you want.
ExtensionAPI Capability Matrix
ExtensionAPI
├── tools
│ ├── defineTool() → register new tool (LLM-callable)
│ └── overrideTool() → replace built-in tool implementation
├── commands
│ └── register() → register /slash commands
├── keybindings
│ └── register() → register keyboard shortcuts
├── events (~40)
│ ├── session_start / session_end
│ ├── turn_start / turn_end
│ ├── tool_before / tool_after
│ ├── session_before_compact / session_after_compact
│ ├── model_change
│ └── ... (full list in types.ts)
├── providers
│ └── registerProvider() → inject custom Provider (→ Chapter 14)
├── ui
│ ├── renderMessage() → custom message rendering
│ ├── addWidget() → status bar widget
│ └── showOverlay() → overlay/dialog
└── editor
└── replace() → replace editor component
loader.ts: jiti + VIRTUAL_MODULES
// loader.ts (conceptual)
import { createJiti } from "jiti"
const VIRTUAL_MODULES = {
"typebox": "@sinclair/typebox",
"pi-ai/compat": "packages/ai/src/compat.ts",
"pi-agent-core": "packages/agent/src/index.ts",
"pi-tui": "packages/tui/src/index.ts",
"pi-coding-agent": "packages/coding-agent/src/index.ts",
}
async function loadExtension(path: string): Promise<ExtensionFn> {
const jiti = createJiti(import.meta.url, {
alias: VIRTUAL_MODULES, // let extensions import these packages
})
const mod = await jiti.import(path)
return mod.default // default export function
}
Why VIRTUAL_MODULES?
- pi compiles to a Bun single-file binary — node_modules doesn't exist
- Extension's
import { Type } from "typebox"can't resolve on the filesystem - VIRTUAL_MODULES redirects these imports to embedded modules
- Result: extension code reads the same as during development; loader handles runtime
runner.ts: Event Dispatch & Conflict Management
class ExtensionRunner {
private handlers = new Map<string, Function[]>()
private tools = new Map<string, ToolDef>()
private keybindings = new Map<string, KeyHandler>()
// Event dispatch: call in registration order
async emit(event: string, payload: any) {
for (const handler of this.handlers.get(event) ?? []) {
await handler(payload) // serial — previous can modify payload
}
}
// Keybinding conflict detection
registerKeybinding(key: string, handler: KeyHandler, extName: string) {
if (RESERVED_KEYBINDINGS.has(key)) {
warn(`${extName}: ${key} is a reserved keybinding`)
return
}
if (this.keybindings.has(key)) {
warn(`${extName}: ${key} is already occupied`)
}
this.keybindings.set(key, handler)
}
}
Events are serial: Not a performance issue (extensions typically <5), but a semantic need — in session_before_compact, the first extension modifies opts.keep, and the second should see the modified result.
Engineering Insights
-
types.ts 1694 lines of pure types: The single reference for the entire API surface. No documentation is more precise than type definitions — IDE autocomplete is the best docs.
-
loader.ts 721 lines: Mostly error handling — extension syntax errors, import failures, circular dependencies. Load failure must not crash pi; it skips the extension + reports.
-
Proof of self-extension: pi's built-in llama.cpp integration is itself an extension written with ExtensionAPI. If the API weren't sufficient, built-in features couldn't be implemented either — this guarantees API completeness.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch01 Agent Loop | Events subscribed by extensions come from agentLoop |
| Prerequisite | Ch10 CLI Modes | Trust gate determines whether extensions load |
| Prerequisite | Ch11 AgentSession | Extension hooks mount on AgentSession |
| Follow-up | Ch14 Model Runtime | registerProvider injects custom Providers |
| Follow-up | Ch03 Compaction | session_before_compact event can protect messages |
| Follow-up | Ch18 Settings & Trust | Extension permissions constrained by trust level |
Check Your Understanding
- If built-in tools used a "privileged path" instead of ExtensionAPI, what long-term problems would arise?
- Why is event dispatch serial rather than parallel? In what scenarios is serial mandatory?
- What problem does VIRTUAL_MODULES solve? Without it, what would happen to extensions in the Bun binary?
Takeaways
- Extension = default export function — minimal contract, no class, no inheritance
- Built-in as extension: same loader, same API — guarantees API completeness
- jiti + VIRTUAL_MODULES: runtime TS loading, Bun binary resolves imports
- ~40 lifecycle events, serial dispatch, covering Agent's full lifecycle
This chapter is based on the main-branch source of earendil-works/pi.