UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,350 lines (1,031 loc) 115 kB
> pi can create extensions. Ask it to build one for your use case. # Extensions Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more. > **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`. **Key capabilities:** - **Custom tools** - Register tools the LLM can call via `pi.registerTool()` - **Event interception** - Block or modify tool calls, inject context, customize compaction - **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions - **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` - **Session persistence** - Store state that survives restarts via `pi.appendEntry()` - **Custom rendering** - Control how tool calls/results and messages appear in TUI **Example use cases:** - Permission gates (confirm before `rm -rf`, `sudo`, etc.) - Git checkpointing (stash at each turn, restore on branch) - Path protection (block writes to `.env`, `node_modules/`) - Custom compaction (summarize conversation your way) - Conversation summaries (see `summarize.ts` example) - Interactive tools (questions, wizards, custom dialogs) - Stateful tools (todo lists, connection pools) - External integrations (file watchers, webhooks, CI triggers) - Games while you wait (see `snake.ts` example) See [examples/extensions/](../examples/extensions/) for working implementations. ## Table of Contents - [Quick Start](#quick-start) - [Extension Locations](#extension-locations) - [Available Imports](#available-imports) - [Writing an Extension](#writing-an-extension) - [Extension Styles](#extension-styles) - [Events](#events) - [Lifecycle Overview](#lifecycle-overview) - [Resource Events](#resource-events) - [Session Events](#session-events) - [Agent Events](#agent-events) - [Model Events](#model-events) - [Tool Events](#tool-events) - [ExtensionContext](#extensioncontext) - [ExtensionCommandContext](#extensioncommandcontext) - [ExtensionAPI Methods](#extensionapi-methods) - [State Management](#state-management) - [Custom Tools](#custom-tools) - [Dynamic Tool Loading](#dynamic-tool-loading) - [Custom UI](#custom-ui) - [Error Handling](#error-handling) - [Mode Behavior](#mode-behavior) - [Examples Reference](#examples-reference) ## Quick Start Create `~/.pi/agent/extensions/my-extension.ts`: ```typescript import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; export default function (pi: ExtensionAPI) { // React to events pi.on("session_start", async (_event, ctx) => { ctx.ui.notify("Extension loaded!", "info"); }); pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); if (!ok) return { block: true, reason: "Blocked by user" }; } }); // Register a custom tool pi.registerTool({ name: "greet", label: "Greet", description: "Greet someone by name", parameters: Type.Object({ name: Type.String({ description: "Name to greet" }), }), async execute(toolCallId, params, signal, onUpdate, ctx) { return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {}, }; }, }); // Register a command pi.registerCommand("hello", { description: "Say hello", handler: async (args, ctx) => { ctx.ui.notify(`Hello ${args || "world"}!`, "info"); }, }); } ``` Test with `--extension` (or `-e`) flag: ```bash pi -e ./my-extension.ts ``` ## Extension Locations > **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted. | Location | Scope | |----------|-------| | `~/.pi/agent/extensions/*.ts` | Global (all projects) | | `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) | | `.pi/extensions/*.ts` | Project-local | | `.pi/extensions/*/index.ts` | Project-local (subdirectory) | Additional paths via `settings.json`: ```json { "packages": [ "npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1" ], "extensions": [ "/path/to/local/extension.ts", "/path/to/local/extension/dir" ] } ``` To share extensions via npm or git as pi packages, see [packages.md](packages.md). ## Available Imports | Package | Purpose | |---------|---------| | `@earendil-works/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | | `typebox` | Schema definitions for tool parameters | | `@earendil-works/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | | `@earendil-works/pi-tui` | TUI components for custom rendering | npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically. For distributed pi packages installed with `pi install` (npm or git), runtime deps must be in `dependencies`. Package installation uses production installs (`npm install --omit=dev`) by default, so `devDependencies` are not available at runtime; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. ## Writing an Extension An extension exports a default factory function that receives `ExtensionAPI`. The factory can be synchronous or asynchronous: ```typescript import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { // Subscribe to events pi.on("event_name", async (event, ctx) => { // ctx.ui for user interaction const ok = await ctx.ui.confirm("Title", "Are you sure?"); ctx.ui.notify("Done!", "info"); ctx.ui.setStatus("my-ext", "Processing..."); // Footer status ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) }); // Register tools, commands, shortcuts, flags pi.registerTool({ ... }); pi.registerCommand("name", { ... }); pi.registerShortcut("ctrl+x", { ... }); pi.registerFlag("my-flag", { ... }); } ``` Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. If the factory returns a `Promise`, pi awaits it before continuing startup. That means async initialization completes before `session_start`, before `resources_discover`, and before provider registrations queued via `pi.registerProvider()` are flushed. ### Async factory functions Use an async factory for one-time startup work such as fetching remote configuration or dynamically discovering available models. ```typescript import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default async function (pi: ExtensionAPI) { const response = await fetch("http://localhost:1234/v1/models"); const payload = (await response.json()) as { data: Array<{ id: string; name?: string; context_window?: number; max_tokens?: number; }>; }; pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, name: model.name ?? model.id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.context_window ?? 128000, maxTokens: model.max_tokens ?? 4096, })), }); } ``` This pattern makes the fetched models available during normal startup and to `pi --list-models`. ### Long-lived resources and shutdown Extension factories may run in invocations that never start a session. Do not start background resources such as processes, sockets, file watchers, or timers from the factory. Defer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start. ### Extension Styles **Single file** - simplest, for small extensions: ``` ~/.pi/agent/extensions/ └── my-extension.ts ``` **Directory with index.ts** - for multi-file extensions: ``` ~/.pi/agent/extensions/ └── my-extension/ ├── index.ts # Entry point (exports default function) ├── tools.ts # Helper module └── utils.ts # Helper module ``` **Package with dependencies** - for extensions that need npm packages: ``` ~/.pi/agent/extensions/ └── my-extension/ ├── package.json # Declares dependencies and entry points ├── package-lock.json ├── node_modules/ # After npm install └── src/ └── index.ts ``` ```json // package.json { "name": "my-extension", "dependencies": { "zod": "^3.0.0", "chalk": "^5.0.0" }, "pi": { "extensions": ["./src/index.ts"] } } ``` Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. ## Events ### Lifecycle Overview ``` pi starts │ ├─► project_trust (user/global and CLI extensions only, before project resources load) ├─► session_start { reason: "startup" } └─► resources_discover { reason: "startup" } │ ▼ user sends prompt ─────────────────────────────────────────┐ │ │ ├─► (extension commands checked first, bypass if found) │ ├─► input (can intercept, transform, or handle) │ ├─► (skill/template expansion if not handled) │ ├─► before_agent_start (can inject message, modify system prompt) ├─► agent_start │ ├─► message_start / message_update / message_end │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ │ ├─► context (can modify messages) │ │ │ ├─► before_provider_headers (can mutate headers) | │ ├─► before_provider_request (can inspect or replace payload) │ ├─► after_provider_response (status + headers, before stream consume) │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_execution_start │ │ │ │ ├─► tool_call (can block) │ │ │ │ ├─► tool_execution_update │ │ │ │ ├─► tool_result (can modify) │ │ │ │ └─► tool_execution_end │ │ │ │ │ │ │ └─► turn_end │ │ │ │ ├─► agent_end │ └─► agent_settled (no retry/compaction/follow-up left) │ │ user sends another prompt ◄────────────────────────────────┘ /new (new session) or /resume (switch session) ├─► session_before_switch (can cancel) ├─► session_shutdown ├─► session_start { reason: "new" | "resume", previousSessionFile? } └─► resources_discover { reason: "startup" } /fork or /clone ├─► session_before_fork (can cancel) ├─► session_shutdown ├─► session_start { reason: "fork", previousSessionFile } └─► resources_discover { reason: "startup" } /name or pi.setSessionName() └─► session_info_changed /compact or auto-compaction ├─► session_before_compact (can cancel or customize) └─► session_compact /tree navigation ├─► session_before_tree (can cancel or customize) └─► session_tree /model or Ctrl+P (model selection/cycling) ├─► thinking_level_select (if model change changes/clamps thinking level) └─► model_select thinking level changes (settings, keybinding, pi.setThinkingLevel()) └─► thinking_level_select exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) └─► session_shutdown ``` ### Startup Events #### project_trust Fired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. ```typescript pi.on("project_trust", async (event, ctx) => { // event.cwd - current working directory // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers if (await ctx.ui.confirm("Trust project?", event.cwd)) { return { trusted: "yes", remember: true }; } return { trusted: "undecided" }; }); ``` A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default. ### Resource Events #### resources_discover Fired after `session_start` so extensions can contribute additional skill, prompt, and theme paths. The startup path uses `reason: "startup"`. Reload uses `reason: "reload"`. ```typescript pi.on("resources_discover", async (event, _ctx) => { // event.cwd - current working directory // event.reason - "startup" | "reload" return { skillPaths: ["/path/to/skills"], promptPaths: ["/path/to/prompts"], themePaths: ["/path/to/themes"], }; }); ``` ### Session Events See [Session Format](session-format.md) for session storage internals and the SessionManager API. #### session_start Fired when a session is started, loaded, or reloaded. ```typescript pi.on("session_start", async (event, ctx) => { // event.reason - "startup" | "reload" | "new" | "resume" | "fork" // event.previousSessionFile - present for "new", "resume", and "fork" ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); }); ``` #### session_info_changed Fired when the current session display name is set via `/name`, RPC, or `pi.setSessionName()`. ```typescript pi.on("session_info_changed", async (event, ctx) => { // event.name - current normalized name, or undefined if cleared ctx.ui.notify(`Session renamed: ${event.name ?? "(none)"}`, "info"); }); ``` #### session_before_switch Fired before starting a new session (`/new`) or switching sessions (`/resume`). ```typescript pi.on("session_before_switch", async (event, ctx) => { // event.reason - "new" or "resume" // event.targetSessionFile - session we're switching to (only for "resume") if (event.reason === "new") { const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); if (!ok) return { cancel: true }; } }); ``` After a successful switch or new-session action, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "new" | "resume"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. #### session_before_fork Fired when forking via `/fork` or cloning via `/clone`. ```typescript pi.on("session_before_fork", async (event, ctx) => { // event.entryId - ID of the selected entry // event.position - "before" for /fork, "at" for /clone return { cancel: true }; // Cancel fork/clone // OR return { skipConversationRestore: true }; // Reserved for future conversation restore control }); ``` After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. #### session_before_compact / session_compact Fired on compaction. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event; // reason - "manual" (/compact), "threshold", or "overflow" // willRetry - whether the aborted turn is retried after compaction (overflow recovery) // Cancel: return { cancel: true }; // Custom summary: return { compaction: { summary: "...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, } }; }); pi.on("session_compact", async (event, ctx) => { // event.compactionEntry - the saved compaction // event.fromExtension - whether extension provided it // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery) }); ``` #### session_before_tree / session_tree Fired on `/tree` navigation. See [Sessions](sessions.md) for tree navigation concepts. ```typescript pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; return { cancel: true }; // OR provide custom summary: return { summary: { summary: "...", details: {} } }; }); pi.on("session_tree", async (event, ctx) => { // event.newLeafId, oldLeafId, summaryEntry, fromExtension }); ``` #### session_shutdown Fired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks. ```typescript pi.on("session_shutdown", async (event, ctx) => { // event.reason - "quit" | "reload" | "new" | "resume" | "fork" // event.targetSessionFile - destination session for session replacement flows // Cleanup, save state, etc. }); ``` ### Agent Events #### before_agent_start Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt. ```typescript pi.on("before_agent_start", async (event, ctx) => { // event.prompt - user's prompt text // event.images - attached images (if any) // event.systemPrompt - current chained system prompt for this handler // (includes changes from earlier before_agent_start handlers) // event.systemPromptOptions - structured options used to build the system prompt // .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates) // .selectedTools - tools currently active in the prompt // .toolSnippets - one-line descriptions for each tool // .promptGuidelines - custom guideline bullets // .appendSystemPrompt - text from --append-system-prompt flags // .cwd - working directory // .contextFiles - AGENTS.md files and other loaded context files // .skills - loaded skills return { // Inject a persistent message (stored in session, sent to LLM) message: { customType: "my-extension", content: "Additional context for the LLM", display: true, }, // Replace the system prompt for this turn (chained across extensions) systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...", }; }); ``` The `systemPromptOptions` field gives extensions access to the same structured data Pi uses to build the system prompt. This lets you inspect what Pi has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration. Inside `before_agent_start`, `event.systemPrompt` and `ctx.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again. #### agent_start / agent_end / agent_settled `agent_start` fires when a low-level agent run begins. `agent_end` fires when that run ends, but Pi may still auto-retry, auto-compact and retry, or continue with queued follow-up messages. Use `agent_settled` for status integrations that need to know Pi will not continue running automatically. ```typescript pi.on("agent_start", async (_event, ctx) => {}); pi.on("agent_end", async (event, ctx) => { // event.messages - messages from this low-level run }); pi.on("agent_settled", async (_event, ctx) => { // ctx.isIdle() is true here unless another extension started a new run. }); ``` #### turn_start / turn_end Fired for each turn (one LLM response + tool calls). ```typescript pi.on("turn_start", async (event, ctx) => { // event.turnIndex, event.timestamp }); pi.on("turn_end", async (event, ctx) => { // event.turnIndex, event.message, event.toolResults }); ``` #### message_start / message_update / message_end Fired for message lifecycle updates. - `message_start` and `message_end` fire for user, assistant, and toolResult messages. - `message_update` fires for assistant streaming updates. - `message_end` handlers can return `{ message }` to replace the finalized message. The replacement must keep the same `role`. ```typescript pi.on("message_start", async (event, ctx) => { // event.message }); pi.on("message_update", async (event, ctx) => { // event.message // event.assistantMessageEvent (token-by-token stream event) }); pi.on("message_end", async (event, ctx) => { if (event.message.role !== "assistant") return; return { message: { ...event.message, usage: { ...event.message.usage, cost: { ...event.message.usage.cost, total: 0.123, }, }, }, }; }); ``` #### tool_execution_start / tool_execution_update / tool_execution_end Fired for tool execution lifecycle updates. In parallel tool mode: - `tool_execution_start` is emitted in assistant source order during the preflight phase - `tool_execution_update` events may interleave across tools - `tool_execution_end` is emitted in tool completion order after each tool is finalized - final `toolResult` message events are still emitted later in assistant source order ```typescript pi.on("tool_execution_start", async (event, ctx) => { // event.toolCallId, event.toolName, event.args }); pi.on("tool_execution_update", async (event, ctx) => { // event.toolCallId, event.toolName, event.args, event.partialResult }); pi.on("tool_execution_end", async (event, ctx) => { // event.toolCallId, event.toolName, event.result, event.isError }); ``` #### context Fired before each LLM call. Modify messages non-destructively. See [Session Format](session-format.md) for message types. ```typescript pi.on("context", async (event, ctx) => { // event.messages - deep copy, safe to modify const filtered = event.messages.filter(m => !shouldPrune(m)); return { messages: filtered }; }); ``` #### before_provider_headers Fired after the outgoing HTTP headers are assembled. Use it to add, override, or remove request headers. Handlers mutate `event.headers` in place. Set a key to a string to add or override it, or to `null` to delete it. ```typescript pi.on("before_provider_headers", (event, ctx) => { // Add or override — e.g. a session id for gateway tracing/attribution event.headers["x-session-id"] = ctx.sessionManager.getSessionId(); // Drop a tracking header pi adds for this call event.headers["X-OpenRouter-Title"] = null; }); ``` Runs once per provider request; retries reuse the same headers rather than re-firing the hook. #### before_provider_request Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request. This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.getSystemPrompt()`, which reports Pi's system prompt string rather than the final serialized provider payload. ```typescript pi.on("before_provider_request", (event, ctx) => { console.log(JSON.stringify(event.payload, null, 2)); // Optional: replace payload // return { ...event.payload, temperature: 0 }; }); ``` This is mainly useful for debugging provider serialization and cache behavior. #### after_provider_response Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order. ```typescript pi.on("after_provider_response", (event, ctx) => { // event.status - HTTP status code // event.headers - normalized response headers if (event.status === 429) { console.log("rate limited", event.headers["retry-after"]); } }); ``` Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers. ### Model Events #### model_select Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. ```typescript pi.on("model_select", async (event, ctx) => { // event.model - newly selected model // event.previousModel - previous model (undefined if first selection) // event.source - "set" | "cycle" | "restore" const prev = event.previousModel ? `${event.previousModel.provider}/${event.previousModel.id}` : "none"; const next = `${event.model.provider}/${event.model.id}`; ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info"); }); ``` Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. #### thinking_level_select Fired when the thinking level changes. This is notification-only; handler return values are ignored. ```typescript pi.on("thinking_level_select", async (event, ctx) => { // event.level - newly selected thinking level // event.previousLevel - previous thinking level ctx.ui.setStatus("thinking", `thinking: ${event.level}`); }); ``` Use this to update extension UI when `pi.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level. ### Tool Events #### tool_call Fired after `tool_execution_start`, before the tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs. Before `tool_call` runs, pi waits for previously emitted Agent events to finish draining through `AgentSession`. This means `ctx.sessionManager` is up to date through the current assistant tool-calling message. In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.sessionManager`. `event.input` is mutable. Mutate it in place to patch tool arguments before execution. Behavior guarantees: - Mutations to `event.input` affect the actual tool execution - Later `tool_call` handlers see mutations made by earlier handlers - No re-validation is performed after your mutation - Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` ```typescript import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash", "read", "write", "edit", etc. // event.toolCallId // event.input - tool parameters (mutable) // Built-in tools: no type params needed if (isToolCallEventType("bash", event)) { // event.input is { command: string; timeout?: number } event.input.command = `source ~/.profile\n${event.input.command}`; if (event.input.command.includes("rm -rf")) { return { block: true, reason: "Dangerous command" }; } } if (isToolCallEventType("read", event)) { // event.input is { path: string; offset?: number; limit?: number } console.log(`Reading: ${event.input.path}`); } }); ``` #### Typing custom tool input Custom tools should export their input type: ```typescript // my-extension.ts export type MyToolInput = Static<typeof myToolSchema>; ``` Use `isToolCallEventType` with explicit type parameters: ```typescript import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; import type { MyToolInput } from "my-extension"; pi.on("tool_call", (event) => { if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { event.input.action; // typed } }); ``` #### tool_result Fired after tool execution finishes and before `tool_execution_end` plus the final tool result message events are emitted. **Can modify result.** In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in tool completion order, while final `toolResult` message events are still emitted later in assistant source order. `tool_result` handlers chain like middleware: - Handlers run in extension load order - Each handler sees the latest result after previous handler changes - Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. ```typescript import { isBashToolResult } from "@earendil-works/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input // event.content, event.details, event.isError if (isBashToolResult(event)) { // event.details is typed as BashToolDetails } const response = await fetch("https://example.com/summarize", { method: "POST", body: JSON.stringify({ content: event.content }), signal: ctx.signal, }); // Modify result: return { content: [...], details: {...}, isError: false }; }); ``` ### User Bash Events #### user_bash Fired when user executes `!` or `!!` commands. **Can intercept.** ```typescript import { createLocalBashOperations } from "@earendil-works/pi-coding-agent"; pi.on("user_bash", (event, ctx) => { // event.command - the bash command // event.excludeFromContext - true if !! prefix // event.cwd - working directory // Option 1: Provide custom operations (e.g., SSH) return { operations: remoteBashOps }; // Option 2: Wrap pi's built-in local bash backend const local = createLocalBashOperations(); return { operations: { exec(command, cwd, options) { return local.exec(`source ~/.profile\n${command}`, cwd, options); } } }; // Option 3: Full replacement - return result directly return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; }); ``` ### Input Events #### input Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded. **Processing order:** 1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped 2. `input` event fires - can intercept, transform, or handle 3. If not handled: skill commands (`/skill:name`) expanded to skill content 4. If not handled: prompt templates (`/template`) expanded to template content 5. Agent processing begins (`before_agent_start`, etc.) ```typescript pi.on("input", async (event, ctx) => { // event.text - raw input (before skill/template expansion) // event.images - attached images, if any // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) // event.streamingBehavior - "steer" | "followUp" | undefined // undefined when idle, "steer" for mid-stream interrupts, // "followUp" for messages queued until the agent finishes // Transform: rewrite input before expansion if (event.text.startsWith("?quick ")) return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; // Handle: respond without LLM (extension shows its own feedback) if (event.text === "ping") { ctx.ui.notify("pong", "info"); return { action: "handled" }; } // Route by source: skip processing for extension-injected messages if (event.source === "extension") return { action: "continue" }; // Intercept skill commands before expansion if (event.text.startsWith("/skill:")) { // Could transform, block, or let pass through } return { action: "continue" }; // Default: pass through to expansion }); ``` **Results:** - `continue` - pass through unchanged (default if handler returns nothing) - `transform` - modify text/images, then continue to expansion - `handled` - skip agent entirely (first handler to return this wins) Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing. ## ExtensionContext All handlers receive `ctx: ExtensionContext`. ### ctx.ui UI methods for user interaction. See [Custom UI](#custom-ui) for full details. ### ctx.mode Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "tui"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering. ### ctx.hasUI `true` in TUI and RPC modes. `false` in print mode (`-p`) and JSON mode. Use this to guard dialog methods (`select`, `confirm`, `input`, `editor`) and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) that work in both TUI and RPC modes. In RPC mode, some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). ### ctx.cwd Current working directory. Use `CONFIG_DIR_NAME` instead of hardcoding `.pi` when constructing project-local config paths. Rebranded distributions can use a different config directory name. ```typescript import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json"); // ... }); } ``` ### ctx.isProjectTrusted() Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. Use this before reading project-local extension configuration that should only be honored for trusted projects. ### ctx.sessionManager Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message. ```typescript ctx.sessionManager.getEntries() // All entries ctx.sessionManager.getBranch() // Current branch ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied ctx.sessionManager.getLeafId() // Current leaf entry ID ``` ### ctx.modelRegistry / ctx.model Access to models and API keys. ### ctx.signal The current agent abort signal, or `undefined` when no agent turn is active. Use this for abort-aware nested work started by extension handlers, for example: - `fetch(..., { signal: ctx.signal })` - model calls that accept `signal` - file or process helpers that accept `AbortSignal` `ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`. It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle. ```typescript pi.on("tool_result", async (event, ctx) => { const response = await fetch("https://example.com/api", { method: "POST", body: JSON.stringify(event), signal: ctx.signal, }); const data = await response.json(); return { details: data }; }); ``` ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() Control flow helpers. `ctx.isIdle()` is false while Pi is processing an agent run, automatic retry, auto-compaction retry, or queued continuation. ### ctx.shutdown() Request a graceful shutdown of pi. - **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages). - **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command). - **Print mode:** No-op. The process exits automatically when all prompts are processed. Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts). ```typescript pi.on("tool_call", (event, ctx) => { if (isFatal(event.input)) { ctx.shutdown(); } }); ``` ### ctx.getContextUsage() Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. ```typescript const usage = ctx.getContextUsage(); if (usage && usage.tokens > 100_000) { // ... } ``` ### ctx.compact() Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions. ```typescript ctx.compact({ customInstructions: "Focus on recent changes", onComplete: (result) => { ctx.ui.notify("Compaction completed", "info"); }, onError: (error) => { ctx.ui.notify(`Compaction failed: ${error.message}`, "error"); }, }); ``` ### ctx.getSystemPrompt() Returns Pi's current system prompt string. - During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn. - It does not include later `context` message mutations. - It does not include `before_provider_request` payload rewrites. - If later-loaded extensions run after yours, they can still change what is ultimately sent. ```typescript pi.on("before_agent_start", (event, ctx) => { const prompt = ctx.getSystemPrompt(); console.log(`System prompt length: ${prompt.length}`); }); ``` ## ExtensionCommandContext Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. ### ctx.getSystemPromptOptions() Returns the base inputs Pi currently uses to build the system prompt. ```typescript const options = ctx.getSystemPromptOptions(); const contextPaths = options.contextFiles?.map((file) => file.path) ?? []; ``` This has the same shape and mutability as `before_agent_start` `event.systemPromptOptions`: custom prompt, active tools, tool snippets, prompt guidelines, appended system prompt text, cwd, loaded context files, and loaded skills. It may include full context file contents, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata. This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites. ### ctx.waitForIdle() Wait for the agent to fully settle, including automatic retries, auto-compaction retries, and queued continuations: ```typescript pi.registerCommand("my-cmd", { handler: async (args, ctx) => { await ctx.waitForIdle(); // Agent is now idle, safe to modify session }, }); ``` ### ctx.newSession(options?) Create a new session: ```typescript const parentSession = ctx.sessionManager.getSessionFile(); const kickoff = "Continue in the replacement session"; const result = await ctx.newSession({ parentSession, setup: async (sm) => { sm.appendMessage({ role: "user", content: [{ type: "text", text: "Context from previous session..." }], timestamp: Date.now(), }); }, withSession: async (ctx) => { // Use only the replacement-session ctx here. await ctx.sendUserMessage(kickoff); }, }); if (result.cancelled) { // An extension cancelled the new session } ``` Options: - `parentSession`: parent session file to record in the new session header - `setup`: mutate the new session's `SessionManager` before `withSession` runs - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). ### ctx.fork(entryId, options?) Fork from a specific entry, creating a new session file: ```typescript const result = await ctx.fork("entry-id-123", { withSession: async (ctx) => { // Use only the replacement-session ctx here. ctx.ui.notify("Now in the forked session", "info"); }, }); if (result.cancelled) { // An extension cancelled the fork } const cloneResult = await ctx.fork("entry-id-456", { position: "at" }); if (cloneResult.cancelled) { // An extension cancelled the clone } ``` Options: - `position`: `"before"` (default) forks before the selected user message, restoring that prompt into the editor - `position`: `"at"` duplicates the active path through the selected entry without restoring editor text - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). ### ctx.navigateTree(targetId, options?) Navigate to a different point in the session tree: ```typescript const result = await ctx.navigateTree("entry-id-456", { summarize: true, customInstructions: "Focus on error handling changes", replaceInstructions: false, // true = replace default prompt entirely label: "review-checkpoint", }); ``` Options: - `summarize`: Whether to generate a summary of the abandoned branch - `customInstructions`: Custom instructions for the summarizer - `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended - `label`: Label to attach to the branch summary entry (or target entry if not summarizing) ### ctx.switchSession(sessionPath, options?) Switch to a different session file: ```typescript const result = await ctx.switchSession("/path/to/session.jsonl", { withSession: async (ctx) => { await ctx.sendUserMessage("Resume work in the replacement session"); }, }); if (result.cancelled) { // An extension cancelled the switch via session_before_switch } ``` Options: - `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). To discover available sessions, use the static `SessionManager.list()` or `SessionManager.listAll()` methods: ```typescript import { SessionManager } from "@earendil-works/pi-coding-agent"; pi.registerCommand("switch", { description: "Switch to another session", handler: async (args, ctx) => { const sessions = await SessionManager.list(ctx.cwd); if (sessions.length === 0) return; const choice = await ctx.ui.select( "Pick session:", sessions.map(s => s.file), ); if (choice) { await ctx.switchSession(choice, { withSession: async (ctx) => { ctx.ui.notify("Switched session", "info"); }, }); } }, }); ``` ### Session replacement lifecycle and footguns `withSession` receives a fresh `ReplacedSessionContext`, which extends `ExtensionCommandContext` with async `sendMessage()` and `sendUserMessage()` helpers bound to the replacement session. Lifecycle and footguns: - `withSession` runs only after the old session has emitted `session_shutdown`, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received `session_start`. - The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before `withSession` starts. - Captured old `pi` / old command `ctx` session-bound objects are stale after replacement and will throw if used. Use only the `ctx` passed to `withSession` for session-bound work. - Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = ctx.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement. - Code in `withSession` should assume any state invalidated by your `session_shutdown` handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config. Safe pattern: ```typescript pi.registerCommand("handoff", { handler: async (_args, ctx) => { const kickoff = "Continue from the replacement session"; await ctx.newSession({ withSession: async (ctx) => { await ctx.sendUserMessage(kickoff); }, }); }, }); ``` Unsafe pattern: ```typescript pi.registerCommand("handoff", { handler: async (_args, ctx) => { const oldSessionManager = ctx.sessionManager; await ctx.newSession({ withSession: async (_ctx) => { // stale old objects: do not do this oldSessionManager.getSessionFile(); pi.sendUserMessage("wrong"); }, }); }, }); ``` ### ctx.reload() Run the same reload flow as `/reload`. ```typescript pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, themes, and context files", handler: async (_args, ctx) => { await ctx.reload(); return; }, }); ``` Important behavior: - `await ctx.reload()` emits `session_shutdown` for the current extension runtime - It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"` - The currently running command handler still continues in the old call frame - Code after `await ctx.reload()` still runs from the pre-reload version - Code after `await ctx.reload()` must not assume old in-memory extension state is still valid - After the handler returns, future commands/events/tool calls use the new extension version For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`). Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message. Example tool the LLM can call to trigger reload: ```typescript import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; export default function (pi: ExtensionAPI) { pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, themes, and context files", handler: async (_args, ctx) => { await ctx.reload(); return; }, }); pi.registerTool({ name: "reload_runtime", label: "Reload Runtime", description: "Reload extensions, skills, prompts, themes, and context files", parameters: Type.Object({}), async execute() { pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); return { content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }], }; }, }); } ``` ## ExtensionAPI Methods ### pi.on(event, handler) Subscribe to events. See [Events](#events) for event types and return values. ### pi.registerTool(definition) Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. `pi.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in `pi.getAllTools()` and are callable by the LLM without `/reload`. Use `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime. Use `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active. **Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead. See [dynamic-tools.ts](../examples/extensions/dynamic-tools.ts) for a full example. ```typescript import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does", pro