> 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) - [Session Events](#session-events) - [Agent Events](#agent-events) - [Tool Events](#tool-events) - [ExtensionContext](#extensioncontext) - [ExtensionCommandContext](#extensioncommandcontext) - [ExtensionAPI Methods](#extensionapi-methods) - [State Management](#state-management) - [Custom Tools](#custom-tools) - [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 "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/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: | 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 | |---------|---------| | `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | | `@sinclair/typebox` | Schema definitions for tool parameters | | `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | | `@mariozechner/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. Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. ## Writing an Extension An extension exports a default function that receives `ExtensionAPI`: ```typescript import type { ExtensionAPI } from "@mariozechner/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!", "success"); 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. ### 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 │ └─► session_start │ ▼ 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 │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ │ ├─► context (can modify messages) │ │ │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_call (can block) │ │ │ │ │ tool executes │ │ │ │ └─► tool_result (can modify) │ │ │ │ │ │ │ └─► turn_end │ │ │ │ └─► agent_end │ │ user sends another prompt ◄────────────────────────────────┘ /new (new session) or /resume (switch session) ├─► session_before_switch (can cancel) └─► session_switch /fork ├─► session_before_fork (can cancel) └─► session_fork /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) └─► model_select exit (Ctrl+C, Ctrl+D) └─► session_shutdown ``` ### Session Events See [session.md](session.md) for session storage internals and the SessionManager API. #### session_start Fired on initial session load. ```typescript pi.on("session_start", async (_event, ctx) => { ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); }); ``` #### session_before_switch / session_switch Fired when 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 }; } }); pi.on("session_switch", async (event, ctx) => { // event.reason - "new" or "resume" // event.previousSessionFile - session we came from }); ``` #### session_before_fork / session_fork Fired when forking via `/fork`. ```typescript pi.on("session_before_fork", async (event, ctx) => { // event.entryId - ID of the entry being forked from return { cancel: true }; // Cancel fork // OR return { skipConversationRestore: true }; // Fork but don't rewind messages }); pi.on("session_fork", async (event, ctx) => { // event.previousSessionFile - previous session file }); ``` #### 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, signal } = event; // 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 }); ``` #### session_before_tree / session_tree Fired on `/tree` navigation. See [tree.md](tree.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 on exit (Ctrl+C, Ctrl+D, SIGTERM). ```typescript pi.on("session_shutdown", async (_event, ctx) => { // 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 system prompt 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...", }; }); ``` #### agent_start / agent_end Fired once per user prompt. ```typescript pi.on("agent_start", async (_event, ctx) => {}); pi.on("agent_end", async (event, ctx) => { // event.messages - messages from this prompt }); ``` #### 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 }); ``` #### context Fired before each LLM call. Modify messages non-destructively. See [session.md](session.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 }; }); ``` ### 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. ### Tool Events #### tool_call Fired before tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs. ```typescript import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash", "read", "write", "edit", etc. // event.toolCallId // event.input - tool parameters // Built-in tools: no type params needed if (isToolCallEventType("bash", event)) { // event.input is { command: string; timeout?: number } 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; ``` Use `isToolCallEventType` with explicit type parameters: ```typescript import { isToolCallEventType } from "@mariozechner/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 executes. **Can modify result.** `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 ```typescript import { isBashToolResult } from "@mariozechner/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 } // Modify result: return { content: [...], details: {...}, isError: false }; }); ``` ### User Bash Events #### user_bash Fired when user executes `!` or `!!` commands. **Can intercept.** ```typescript 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: 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) // 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). ## ExtensionContext Every handler receives `ctx: ExtensionContext`: ### ctx.ui UI methods for user interaction. See [Custom UI](#custom-ui) for full details. ### ctx.hasUI `false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`. ### ctx.cwd Current working directory. ### ctx.sessionManager Read-only access to session state. See [session.md](session.md) for the full SessionManager API and entry types. ```typescript ctx.sessionManager.getEntries() // All entries ctx.sessionManager.getBranch() // Current branch ctx.sessionManager.getLeafId() // Current leaf entry ID ``` ### ctx.modelRegistry / ctx.model Access to models and API keys. ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() Control flow helpers. ### 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 the current effective system prompt. This includes any modifications made by `before_agent_start` handlers for the current turn. ```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.waitForIdle() Wait for the agent to finish streaming: ```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 result = await ctx.newSession({ parentSession: ctx.sessionManager.getSessionFile(), setup: async (sm) => { sm.appendMessage({ role: "user", content: [{ type: "text", text: "Context from previous session..." }], timestamp: Date.now(), }); }, }); if (result.cancelled) { // An extension cancelled the new session } ``` ### ctx.fork(entryId) Fork from a specific entry, creating a new session file: ```typescript const result = await ctx.fork("entry-id-123"); if (!result.cancelled) { // Now in the forked session } ``` ### 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.reload() Run the same reload flow as `/reload`. ```typescript pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, and themes", 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` (and `resources_discover` with reason `"reload"`) for the new runtime - 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 "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; export default function (pi: ExtensionAPI) { pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, and themes", handler: async (_args, ctx) => { await ctx.reload(); return; }, }); pi.registerTool({ name: "reload_runtime", label: "Reload Runtime", description: "Reload extensions, skills, prompts, and themes", 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. ```typescript import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does", parameters: Type.Object({ action: StringEnum(["list", "add"] as const), text: Type.Optional(Type.String()), }), async execute(toolCallId, params, signal, onUpdate, ctx) { // Stream progress onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); return { content: [{ type: "text", text: "Done" }], details: { result: "..." }, }; }, // Optional: Custom rendering renderCall(args, theme) { ... }, renderResult(result, options, theme) { ... }, }); ``` ### pi.sendMessage(message, options?) Inject a custom message into the session. ```typescript pi.sendMessage({ customType: "my-extension", content: "Message text", display: true, details: { ... }, }, { triggerTurn: true, deliverAs: "steer", }); ``` **Options:** - `deliverAs` - Delivery mode: - `"steer"` (default) - Interrupts streaming. Delivered after current tool finishes, remaining tools skipped. - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls. - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). ### pi.sendUserMessage(content, options?) Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. ```typescript // Simple text message pi.sendUserMessage("What is 2+2?"); // With content array (text + images) pi.sendUserMessage([ { type: "text", text: "Describe this image:" }, { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }, ]); // During streaming - must specify delivery mode pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); ``` **Options:** - `deliverAs` - Required when agent is streaming: - `"steer"` - Interrupts after current tool, remaining tools skipped - `"followUp"` - Waits for agent to finish all tools When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example. ### pi.appendEntry(customType, data?) Persist extension state (does NOT participate in LLM context). ```typescript pi.appendEntry("my-state", { count: 42 }); // Restore on reload pi.on("session_start", async (_event, ctx) => { for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === "my-state") { // Reconstruct from entry.data } } }); ``` ### pi.setSessionName(name) Set the session display name (shown in session selector instead of first message). ```typescript pi.setSessionName("Refactor auth module"); ``` ### pi.getSessionName() Get the current session name, if set. ```typescript const name = pi.getSessionName(); if (name) { console.log(`Session: ${name}`); } ``` ### pi.setLabel(entryId, label) Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector). ```typescript // Set a label pi.setLabel(entryId, "checkpoint-before-refactor"); // Clear a label pi.setLabel(entryId, undefined); // Read labels via sessionManager const label = ctx.sessionManager.getLabel(entryId); ``` Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree. ### pi.registerCommand(name, options) Register a command. ```typescript pi.registerCommand("stats", { description: "Show session statistics", handler: async (args, ctx) => { const count = ctx.sessionManager.getEntries().length; ctx.ui.notify(`${count} entries`, "info"); } }); ``` Optional: add argument auto-completion for `/command ...`: ```typescript import type { AutocompleteItem } from "@mariozechner/pi-tui"; pi.registerCommand("deploy", { description: "Deploy to an environment", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const envs = ["dev", "staging", "prod"]; const items = envs.map((e) => ({ value: e, label: e })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { ctx.ui.notify(`Deploying: ${args}`, "info"); }, }); ``` ### pi.getCommands() Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands. The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills. ```typescript const commands = pi.getCommands(); const bySource = commands.filter((command) => command.source === "extension"); ``` Each entry has this shape: ```typescript { name: string; // Command name without the leading slash description?: string; source: "extension" | "prompt" | "skill"; location?: "user" | "project" | "path"; // For templates and skills path?: string; // Files backing templates, skills, and extensions } ``` Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive mode and would not execute if sent via `prompt`. ### pi.registerMessageRenderer(customType, renderer) Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). ### pi.registerShortcut(shortcut, options) Register a keyboard shortcut. See [keybindings.md](keybindings.md) for the shortcut format and built-in keybindings. ```typescript pi.registerShortcut("ctrl+shift+p", { description: "Toggle plan mode", handler: async (ctx) => { ctx.ui.notify("Toggled!"); }, }); ``` ### pi.registerFlag(name, options) Register a CLI flag. ```typescript pi.registerFlag("plan", { description: "Start in plan mode", type: "boolean", default: false, }); // Check value if (pi.getFlag("--plan")) { // Plan mode enabled } ``` ### pi.exec(command, args, options?) Execute a shell command. ```typescript const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); // result.stdout, result.stderr, result.code, result.killed ``` ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) Manage active tools. ```typescript const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] const all = pi.getAllTools(); // [{ name: "read", description: "Read file contents..." }, ...] const names = all.map(t => t.name); // Just names if needed pi.setActiveTools(["read", "bash"]); // Switch to read-only ``` ### pi.setModel(model) Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models. ```typescript const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (model) { const success = await pi.setModel(model); if (!success) { ctx.ui.notify("No API key for this model", "error"); } } ``` ### pi.getThinkingLevel() / pi.setThinkingLevel(level) Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). ```typescript const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" pi.setThinkingLevel("high"); ``` ### pi.events Shared event bus for communication between extensions: ```typescript pi.events.on("my:event", (data) => { ... }); pi.events.emit("my:event", { ... }); ``` ### pi.registerProvider(name, config) Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations. ```typescript // Register a new provider with custom models pi.registerProvider("my-proxy", { baseUrl: "https://proxy.example.com", apiKey: "PROXY_API_KEY", // env var name or literal api: "anthropic-messages", models: [ { id: "claude-sonnet-4-20250514", name: "Claude 4 Sonnet (proxy)", reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16384 } ] }); // Override baseUrl for an existing provider (keeps all models) pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" }); // Register provider with OAuth support for /login pi.registerProvider("corporate-ai", { baseUrl: "https://ai.corp.com", api: "openai-responses", models: [...], oauth: { name: "Corporate AI (SSO)", async login(callbacks) { // Custom OAuth flow callbacks.onAuth({ url: "https://sso.corp.com/..." }); const code = await callbacks.onPrompt({ message: "Enter code:" }); return { refresh: code, access: code, expires: Date.now() + 3600000 }; }, async refreshToken(credentials) { // Refresh logic return credentials; }, getApiKey(credentials) { return credentials.access; } } }); ``` **Config options:** - `baseUrl` - API endpoint URL. Required when defining models. - `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. - `headers` - Custom headers to include in requests. - `authHeader` - If true, adds `Authorization: Bearer` header automatically. - `models` - Array of model definitions. If provided, replaces all existing models for this provider. - `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu. - `streamSimple` - Custom streaming implementation for non-standard APIs. See [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference. ## State Management Extensions with state should store it in tool result `details` for proper branching support: ```typescript export default function (pi: ExtensionAPI) { let items: string[] = []; // Reconstruct state from session pi.on("session_start", async (_event, ctx) => { items = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "toolResult") { if (entry.message.toolName === "my_tool") { items = entry.message.details?.items ?? []; } } } }); pi.registerTool({ name: "my_tool", // ... async execute(toolCallId, params, signal, onUpdate, ctx) { items.push("new item"); return { content: [{ type: "text", text: "Added" }], details: { items: [...items] }, // Store for reconstruction }; }, }); } ``` ## Custom Tools Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering. Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well. ### Tool Definition ```typescript import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text } from "@mariozechner/pi-tui"; pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does (shown to LLM)", parameters: Type.Object({ action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility text: Type.Optional(Type.String()), }), async execute(toolCallId, params, signal, onUpdate, ctx) { // Check for cancellation if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; } // Stream progress updates onUpdate?.({ content: [{ type: "text", text: "Working..." }], details: { progress: 50 }, }); // Run commands via pi.exec (captured from extension closure) const result = await pi.exec("some-command", [], { signal }); // Return result return { content: [{ type: "text", text: "Done" }], // Sent to LLM details: { data: result }, // For rendering & state }; }, // Optional: Custom rendering renderCall(args, theme) { ... }, renderResult(result, options, theme) { ... }, }); ``` **Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. ### Overriding Built-in Tools Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. ```bash # Extension's read tool replaces built-in read pi -e ./tool-override.ts ``` Alternatively, use `--no-tools` to start without any built-in tools: ```bash # No built-in tools, only extension tools pi --no-tools -e ./my-extension.ts ``` See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control. **Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. **Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. Built-in tool implementations: - [read.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` - [bash.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` - [edit.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) - [write.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) - [grep.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` - [find.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails` - [ls.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails` ### Remote Execution Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): ```typescript import { createReadTool, createBashTool, type ReadOperations } from "@mariozechner/pi-coding-agent"; // Create tool with custom operations const remoteRead = createReadTool(cwd, { operations: { readFile: (path) => sshExec(remote, `cat ${path}`), access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), } }); // Register, checking flag at execution time pi.registerTool({ ...remoteRead, async execute(id, params, signal, onUpdate, _ctx) { const ssh = getSshConfig(); if (ssh) { const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); return tool.execute(id, params, signal, onUpdate); } return localRead.execute(id, params, signal, onUpdate); }, }); ``` **Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: ```typescript import { createBashTool } from "@mariozechner/pi-coding-agent"; const bashTool = createBashTool(cwd, { spawnHook: ({ command, cwd, env }) => ({ command: `source ~/.profile\n${command}`, cwd: `/mnt/sandbox${cwd}`, env: { ...env, CI: "1" }, }), }); ``` See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation **Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: - Context overflow errors (prompt too long) - Compaction failures - Degraded model performance The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities: ```typescript import { truncateHead, // Keep first N lines/bytes (good for file reads, search results) truncateTail, // Keep last N lines/bytes (good for logs, command output) truncateLine, // Truncate a single line to maxBytes with ellipsis formatSize, // Human-readable size (e.g., "50KB", "1.5MB") DEFAULT_MAX_BYTES, // 50KB DEFAULT_MAX_LINES, // 2000 } from "@mariozechner/pi-coding-agent"; async execute(toolCallId, params, signal, onUpdate, ctx) { const output = await runCommand(); // Apply truncation const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); let result = truncation.content; if (truncation.truncated) { // Write full output to temp file const tempFile = writeTempFile(output); // Inform the LLM where to find complete output result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; result += ` Full output saved to: ${tempFile}]`; } return { content: [{ type: "text", text: result }] }; } ``` **Key points:** - Use `truncateHead` for content where the beginning matters (search results, file reads) - Use `truncateTail` for content where the end matters (logs, command output) - Always inform the LLM when output is truncated and where to find the full version - Document the truncation limits in your tool's description See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation. ### Multiple Tools One extension can register multiple tools with shared state: ```typescript export default function (pi: ExtensionAPI) { let connection = null; pi.registerTool({ name: "db_connect", ... }); pi.registerTool({ name: "db_query", ... }); pi.registerTool({ name: "db_close", ... }); pi.on("session_shutdown", async () => { connection?.close(); }); } ``` ### Custom Rendering Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how built-in tools render. Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`). #### renderCall Renders the tool call (before/during execution): ```typescript import { Text } from "@mariozechner/pi-tui"; renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("my_tool ")); text += theme.fg("muted", args.action); if (args.text) { text += " " + theme.fg("dim", `"${args.text}"`); } return new Text(text, 0, 0); // 0,0 padding - Box handles it } ``` #### renderResult Renders the tool result: ```typescript renderResult(result, { expanded, isPartial }, theme) { // Handle streaming if (isPartial) { return new Text(theme.fg("warning", "Processing..."), 0, 0); } // Handle errors if (result.details?.error) { return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); } // Normal result - support expanded view (Ctrl+O) let text = theme.fg("success", "✓ Done"); if (expanded && result.details?.items) { for (const item of result.details.items) { text += "\n " + theme.fg("dim", item); } } return new Text(text, 0, 0); } ``` #### Keybinding Hints Use `keyHint()` to display keybinding hints that respect user's keybinding configuration: ```typescript import { keyHint } from "@mariozechner/pi-coding-agent"; renderResult(result, { expanded }, theme) { let text = theme.fg("success", "✓ Done"); if (!expanded) { text += ` (${keyHint("expandTools", "to expand")})`; } return new Text(text, 0, 0); } ``` Available functions: - `keyHint(action, description)` - Editor actions (e.g., `"expandTools"`, `"selectConfirm"`) - `appKeyHint(keybindings, action, description)` - App actions (requires `KeybindingsManager`) - `editorKey(action)` - Get raw key string for editor action - `rawKeyHint(key, description)` - Format a raw key string #### Best Practices - Use `Text` with padding `(0, 0)` - the Box handles padding - Use `\n` for multi-line content - Handle `isPartial` for streaming progress - Support `expanded` for detail on demand - Keep default view compact #### Fallback If `renderCall`/`renderResult` is not defined or throws: - `renderCall`: Shows tool name - `renderResult`: Shows raw text from `content` ## Custom UI Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. **For custom components, see [tui.md](tui.md)** which has copy-paste patterns for: - Selection dialogs (SelectList) - Async operations with cancel (BorderedLoader) - Settings toggles (SettingsList) - Status indicators (setStatus) - Working message during streaming (setWorkingMessage) - Widgets above/below editor (setWidget) - Custom footers (setFooter) ### Dialogs ```typescript // Select from options const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // Confirm dialog const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // Text input const name = await ctx.ui.input("Name:", "placeholder"); // Multi-line editor const text = await ctx.ui.editor("Edit:", "prefilled text"); // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" ``` #### Timed Dialogs with Countdown Dialogs support a `timeout` option that auto-dismisses with a live countdown display: ```typescript // Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0 const confirmed = await ctx.ui.confirm( "Timed Confirmation", "This dialog will auto-cancel in 5 seconds. Confirm?", { timeout: 5000 } ); if (confirmed) { // User confirmed } else { // User cancelled or timed out } ``` **Return values on timeout:** - `select()` returns `undefined` - `confirm()` returns `false` - `input()` returns `undefined` #### Manual Dismissal with AbortSignal For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`: ```typescript const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const confirmed = await ctx.ui.confirm( "Timed Confirmation", "This dialog will auto-cancel in 5 seconds. Confirm?", { signal: controller.signal } ); clearTimeout(timeoutId); if (confirmed) { // User confirmed } else if (controller.signal.aborted) { // Dialog timed out } else { // User cancelled (pressed Escape or selected "No") } ``` See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples. ### Widgets, Status, and Footer ```typescript // Status in footer (persistent until cleared) ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", undefined); // Clear // Working message (shown during streaming) ctx.ui.setWorkingMessage("Thinking deeply..."); ctx.ui.setWorkingMessage(); // Restore default // Widget above editor (default) ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); // Widget below editor ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" }); ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); ctx.ui.setWidget("my-widget", undefined); // Clear // Custom footer (replaces built-in footer entirely) ctx.ui.setFooter((tui, theme) => ({ render(width) { return [theme.fg("dim", "Custom footer")]; }, invalidate() {}, })); ctx.ui.setFooter(undefined); // Restore built-in footer // Terminal title ctx.ui.setTitle("pi - my-project"); // Editor text ctx.ui.setEditorText("Prefill text"); const current = ctx.ui.getEditorText(); // Paste into editor (triggers paste handling, including collapse for large content) ctx.ui.pasteToEditor("pasted content"); // Tool output expansion const wasExpanded = ctx.ui.getToolsExpanded(); ctx.ui.setToolsExpanded(true); ctx.ui.setToolsExpanded(wasExpanded); // Custom editor (vim mode, emacs mode, etc.) ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings)); ctx.ui.setEditorComponent(undefined); // Restore default editor // Theme management (see themes.md for creating themes) const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...] const lightTheme = ctx.ui.getTheme("light"); // Load without switching const result = ctx.ui.setTheme("light"); // Switch by name if (!result.success) { ctx.ui.notify(`Failed: ${result.error}`, "error"); } ctx.ui.setTheme(lightTheme!); // Or switch by Theme object ctx.ui.theme.fg("accent", "styled text"); // Access current theme ``` ### Custom Components For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: ```typescript import { Text, Component } from "@mariozechner/pi-tui"; const result = await ctx.ui.custom((tui, theme, keybindings, done) => { const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); text.onKey = (key) => { if (key === "return") done(true); if (key === "escape") done(false); return true; }; return text; }); if (result) { // User pressed Enter } ``` The callback receives: - `tui` - TUI instance (for screen dimensions, focus management) - `theme` - Current theme for styling - `keybindings` - App keybinding manager (for checking shortcuts) - `done(value)` - Call to close component and return value See [tui.md](tui.md) for the full component API. #### Overlay Mode (Experimental) Pass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen: ```typescript const result = await ctx.ui.custom( (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), { overlay: true } ); ``` For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: ```typescript const result = await ctx.ui.custom( (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, onHandle: (handle) => { /* handle.setHidden(true/false) */ } } ); ``` See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. ### Custom Editor Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.): ```typescript import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { matchesKey } from "@mariozechner/pi-tui"; class VimEditor extends CustomEditor { private mode: "normal" | "insert" = "insert"; handleInput(data: string): void { if (matchesKey(data, "escape") && this.mode === "insert") { this.mode = "normal"; return; } if (this.mode === "normal" && data === "i") { this.mode = "insert"; return; } super.handleInput(data); // App keybindings + text editing } } export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings) ); }); } ``` **Key points:** - Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching) - Call `super.handleInput(data)` for keys you don't handle - Factory receives `theme` and `keybindings` from the app - Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)` See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator. ### Message Rendering Register a custom renderer for messages with your `customType`: ```typescript import { Text } from "@mariozechner/pi-tui"; pi.registerMessageRenderer("my-extension", (message, options, theme) => { const { expanded } = options; let text = theme.fg("accent", `[${message.customType}] `); text += message.content; if (expanded && message.details) { text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); } return new Text(text, 0, 0); }); ``` Messages are sent via `pi.sendMessage()`: ```typescript pi.sendMessage({ customType: "my-extension", // Matches registerMessageRenderer content: "Status update", display: true, // Show in TUI details: { ... }, // Available in renderer }); ``` ### Theme Colors All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette. ```typescript // Foreground colors theme.fg("toolTitle", text) // Tool names theme.fg("accent", text) // Highlights theme.fg("success", text) // Success (green) theme.fg("error", text) // Errors (red) theme.fg("warning", text) // Warnings (yellow) theme.fg("muted", text) // Secondary text theme.fg("dim", text) // Tertiary text // Text styles theme.bold(text) theme.italic(text) theme.strikethrough(text) ``` For syntax highlighting in custom tool renderers: ```typescript import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent"; // Highlight code with explicit language const highlighted = highlightCode("const x = 1;", "typescript", theme); // Auto-detect language from file path const lang = getLanguageFromPath("/path/to/file.rs"); // "rust" const highlighted = highlightCode(code, lang, theme); ``` ## Error Handling - Extension errors are logged, agent continues - `tool_call` errors block the tool (fail-safe) - Tool `execute` errors are reported to the LLM with `isError: true` ## Mode Behavior | Mode | UI Methods | Notes | |------|-----------|-------| | Interactive | Full TUI | Normal operation | | RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) | | JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) | | Print (`-p`) | No-op | Extensions run but can't prompt | In non-interactive modes, check `ctx.hasUI` before using UI methods. ## Examples Reference All examples in [examples/extensions/](../examples/extensions/). | Example | Description | Key APIs | |---------|-------------|----------| | **Tools** ||| | `hello.ts` | Minimal tool registration | `registerTool` | | `question.ts` | Tool with user interaction | `registerTool`, `ui.select` | | `questionnaire.ts` | Multi-step wizard tool | `registerTool`, `ui.custom` | | `todo.ts` | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult`, session events | | `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` | | `tool-override.ts` | Override built-in read tool | `registerTool` (same name as built-in) | | **Commands** ||| | `pirate.ts` | Modify system prompt per-turn | `registerCommand`, `before_agent_start` | | `summarize.ts` | Conversation summary command | `registerCommand`, `ui.custom` | | `handoff.ts` | Cross-provider model handoff | `registerCommand`, `ui.editor`, `ui.custom` | | `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom`, `setEditorText` | | `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` | | `reload-runtime.ts` | Reload command and LLM tool handoff | `registerCommand`, `ctx.reload()`, `sendUserMessage` | | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | **Events & Gates** ||| | `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | | `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | | `input-transform.ts` | Transform user input | `on("input")` | | `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` | | `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` | | `claude-rules.ts` | Load rules from files | `on("session_start")`, `on("before_agent_start")` | | `file-trigger.ts` | File watcher triggers messages | `sendMessage` | | **Compaction & Sessions** ||| | `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | | `trigger-compact.ts` | Trigger compaction manually | `compact()` | | `git-checkpoint.ts` | Git stash on turns | `on("turn_end")`, `on("session_fork")`, `exec` | | `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | | **UI Components** ||| | `status-line.ts` | Footer status indicator | `setStatus`, session events | | `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` | | `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` | | `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` | | `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` | | `widget-placement.ts` | Widget above/below editor | `setWidget` | | `overlay-test.ts` | Overlay components | `ui.custom` with overlay options | | `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options | | `notify.ts` | Simple notifications | `ui.notify` | | `timed-confirm.ts` | Dialogs with timeout | `ui.confirm` with timeout/signal | | `mac-system-theme.ts` | Auto-switch theme | `setTheme`, `exec` | | **Complex Extensions** ||| | `plan-mode/` | Full plan mode implementation | All event types, `registerCommand`, `registerShortcut`, `registerFlag`, `setStatus`, `setWidget`, `sendMessage`, `setActiveTools` | | `preset.ts` | Saveable presets (model, tools, thinking) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools`, `setThinkingLevel`, `appendEntry` | | `tools.ts` | Toggle tools on/off UI | `registerCommand`, `setActiveTools`, `SettingsList`, session events | | **Remote & Sandbox** ||| | `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations | | `interactive-shell.ts` | Persistent shell session | `on("user_bash")` | | `sandbox/` | Sandboxed tool execution | Tool operations | | `subagent/` | Spawn sub-agents | `registerTool`, `exec` | | **Games** ||| | `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling | | `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` | | `doom-overlay/` | Doom in overlay | `ui.custom` with overlay | | **Providers** ||| | `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` | | `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth | | **Messages & Communication** ||| | `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` | | `event-bus.ts` | Inter-extension events | `pi.events` | | **Session Metadata** ||| | `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` | | `bookmark.ts` | Bookmark entries for /tree | `setLabel` | | **Misc** ||| | `antigravity-image-gen.ts` | Image generation tool | `registerTool`, Google Antigravity | | `inline-bash.ts` | Inline bash in tool calls | `on("tool_call")` | | `bash-spawn-hook.ts` | Adjust bash command, cwd, and env before execution | `createBashTool`, `spawnHook` | | `with-deps/` | Extension with npm dependencies | Package structure with `package.json` |