Pi Extension Mechanism & Internals
Pi’s Extension API design philosophy, tool registration, the hook system, and the full load → bind → dispatch pipeline.
30-second takeaway
Pi is a pluginized event-driven kernel: load (jiti) → bind (real session) → dispatch (fire-and-forget / cancel / chain). Extensions own behavior; the core stays minimal.
Design philosophy: why extensions, not built-ins?
Pi’s core idea is extreme extensibility, minimal core. Features other AI coding assistants ship built-in—Plan Mode, Sub-agents, MCP, permission popups, todos, background shell—Pi deliberately omits. Instead it offers an Extension mechanism so users build what they need.
Pi is aggressively extensible so it doesn't have to dictate your workflow.
The kernel does the minimum: four base tools (read, write, edit, bash) plus a basic TUI. Advanced behavior lives in extensions without invading core code.
Three-layer architecture
ExtensionFactory (user default export)
│
▼
┌──────────────────────────────────────────┐
│ loader.ts — load & discover & API │
│ 1. discover paths (global/project/cfg) │
│ 2. jiti loads TypeScript at runtime │
│ 3. run factory → fill Extension object │
│ 4. create shared ExtensionRuntime │
└──────────────┬───────────────────────────┘
│ Extensions[] + Runtime
▼
┌──────────────────────────────────────────┐
│ runner.ts — orchestrate & dispatch │
│ 1. bindCore() injects real impls │
│ 2. createContext() builds ctx │
│ 3. dispatch events in load order │
│ 4. isolate errors │
└──────────────┬───────────────────────────┘
│ AgentTools
▼
┌──────────────────────────────────────────┐
│ wrapper.ts — tool wrapping │
│ ToolDefinition → AgentTool │
└──────────────────────────────────────────┘1. Loader (loader.ts)
1.1 Runtime module resolution
Extensions are TypeScript files loaded by jiti at runtime—no compile step. Module aliases matter:
| Mode | Resolution | Notes |
|---|---|---|
| Bun binary | virtualModules | inject pi packages into jiti cache |
| Node.js / dev | alias | map to local workspace paths |
import { ExtensionAPI } from "@earendil-works/pi-coding-agent" is redirected to Pi’s bundled instance for version consistency.
1.2 Discovery
discoverAndLoadExtensions() scans three places by priority:
| Priority | Location | Scope |
|---|---|---|
| 1 | {cwd}/.pi/extensions/ | project (if trusted) |
| 2 | ~/.pi/agent/extensions/ | global |
| 3 | settings.json extensions field | explicit |
One level only (no deep recursion): single files, package subdirs with pi.extensions or index.ts, optional node_modules.
1.3 Extension object model
Each load produces an Extension with Maps for handlers, tools, commands, flags, shortcuts, renderers. Ownership is clear: each extension owns its data; nothing is shared across extensions at the registration layer.
1.4 ExtensionAPI two-path delegation
pi.registerTool() → extension.tools.set() (owned by Extension)
pi.on("event", fn) → extension.handlers…
pi.registerCommand() → extension.commands.set()
pi.sendMessage() → runtime.sendMessage() (shared Runtime)
pi.setActiveTools() → runtime.setActiveTools()
pi.exec() → runtimeRegistration mutates extension-local data. Action methods need the session system; hence a shared backend.
1.5 ExtensionRuntime: delayed binding
All action methods start as throwing stubs. Loading is a pure declaration phase: no side effects. bindCore() replaces stubs with real AgentSession implementations.
Provider registration is queued into pendingProviderRegistrations and flushed at bindCore() so ModelRegistry need not be ready yet.
1.6 Factory cache
extensionCache keyed by cwd + generation. Project switch clears it; /reload bumps generation. Same project restarts avoid recompilation.
2. Runner (runner.ts)
2.1 ExtensionRunner
Holds extensions[], shared runtime, mode-specific uiContext, sessionManager, modelRegistry.
2.2 bindCore
runner.bindCore(
actions, // sendMessage, appendEntry, …
contextActions, // getModel, isIdle, abort, compact, …
providerActions, // optional register/unregister
);After bind: stubs → real functions; provider queue flushes; later provider calls apply immediately.
2.3 createContext: lazy getters
return {
get ui() { return runner.uiContext; },
get cwd() { return runner.cwd; },
get model() { return getModel(); },
get signal() { return runner.getSignalFn(); },
…
};Getters (not closure snapshots) keep ctx fresh across session switch / reload.
2.4 Three dispatch modes
Events run serially in load order. Errors are try/caught per handler and never propagate: one bad extension cannot crash Pi.
| Mode | Events | Behavior |
|---|---|---|
| Fire-and-forget | session_start, turn_end, … | ignore return |
| First-cancel-wins | session_before_switch/fork/compact/tree | first { cancel: true } stops chain |
| Chain | tool_result, context, before_agent_start, … | each handler mutates; next sees previous result |
before_agent_start specially rebinds ctx.getSystemPrompt() so chained system-prompt edits compose.
2.5 Shortcut conflicts
| Level | Handling |
|---|---|
restrictOverride: true | hard ban (e.g. Ctrl+C); warn + skip |
restrictOverride: false | allow override with info warning |
| inter-extension | later wins + warning |
2.6 Stale context
After session switch / fork / reload, invalidate() marks old pi/ctx stale; further use throws—prevents the classic “used after replace” bug class.
3. Wrapper (wrapper.ts)
3.1 Registered tools → AgentTool
wrapRegisteredTool creates a fresh context on each execute. If the tool dynamically registerTools during execute, addedToolNames notifies the system to refresh the tool set for the next LLM turn.
3.2 Overriding built-ins
Same-name registration overrides built-ins (read / bash / edit). Omitted render slots inherit built-in renderCall/renderResult—log/permission wrappers only need execute.
4. Full lifecycle
1. start → discoverAndLoadExtensions()
2. AgentSession init → ExtensionRunner + bindCore + UI
3. project_trust (user/global extensions only)
4. session_start
5. resources_discover
═══ user input loop ═══
6. input pipeline (commands / emitInput / skills)
7. before_agent_start
8. Agent loop per turn:
emitContext → provider hooks → LLM
per tool: emitToolCall → execute → emitToolResult → emitMessageEnd
turn_end
9. agent_end → agent_settled
10. exit / reload / session switch:
session_shutdown → invalidate → rebuild runner5. Design takeaways
| Design | Implementation | Goal |
|---|---|---|
| Clear ownership | register → Extension; actions → Runtime | isolation |
| Delayed binding | throwing stubs until bindCore | pure load phase |
| Lazy context | getters | no stale snapshots |
| Serial dispatch | load order | predictable |
| Errors never propagate | try/catch + emitError | resilience |
| Three dispatch modes | FAF / Cancel / Chain | match event semantics |
| Stale protection | invalidate() | safe session replace |
| Provider queue | flush at bindCore | ordering |
Pi’s Extension system is a pluginized event-driven architecture: kernel does the minimum (four tools + TUI), and extensions own behavior. That lets Pi fit any workflow without imposing one.
Source Markdown: docs/notes/pi-extension-mechanism.md