2026-07-14 Pi Extension

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

text
  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:

ModeResolutionNotes
Bun binaryvirtualModulesinject pi packages into jiti cache
Node.js / devaliasmap 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:

PriorityLocationScope
1{cwd}/.pi/extensions/project (if trusted)
2~/.pi/agent/extensions/global
3settings.json extensions fieldexplicit

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

text
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()            →  runtime

Registration 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

typescript
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

typescript
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.

ModeEventsBehavior
Fire-and-forgetsession_start, turn_end, …ignore return
First-cancel-winssession_before_switch/fork/compact/treefirst { cancel: true } stops chain
Chaintool_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

LevelHandling
restrictOverride: truehard ban (e.g. Ctrl+C); warn + skip
restrictOverride: falseallow override with info warning
inter-extensionlater 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

text
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 runner

5. Design takeaways

DesignImplementationGoal
Clear ownershipregister → Extension; actions → Runtimeisolation
Delayed bindingthrowing stubs until bindCorepure load phase
Lazy contextgettersno stale snapshots
Serial dispatchload orderpredictable
Errors never propagatetry/catch + emitErrorresilience
Three dispatch modesFAF / Cancel / Chainmatch event semantics
Stale protectioninvalidate()safe session replace
Provider queueflush at bindCoreordering

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