ππ Agent
Chapter 13 / 21

The Extension System

Self-extensibility: one loader powers built-ins & users

~40 lifecycle events; jiti + VIRTUAL_MODULES for the Bun binary
No simulation scenario available for this chapter

Deep Dive

~7 min read·7369 chars

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

FileLinesResponsibility
packages/coding-agent/src/core/extensions/types.ts1694Full ExtensionAPI type definitions
packages/coding-agent/src/core/extensions/runner.ts1223Event dispatch, tool/command/keybinding management
packages/coding-agent/src/core/extensions/loader.ts721jiti 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

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

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

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

DirectionChapterRelationship
PrerequisiteCh01 Agent LoopEvents subscribed by extensions come from agentLoop
PrerequisiteCh10 CLI ModesTrust gate determines whether extensions load
PrerequisiteCh11 AgentSessionExtension hooks mount on AgentSession
Follow-upCh14 Model RuntimeregisterProvider injects custom Providers
Follow-upCh03 Compactionsession_before_compact event can protect messages
Follow-upCh18 Settings & TrustExtension permissions constrained by trust level

Check Your Understanding

  1. If built-in tools used a "privileged path" instead of ExtensionAPI, what long-term problems would arise?
  2. Why is event dispatch serial rather than parallel? In what scenarios is serial mandatory?
  3. 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.