From b85bf054610439a1bfc5adc66b82810964443a46 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 26 Jan 2026 02:40:46 +0100 Subject: [PATCH] docs(coding-agent): restructure extensions.md, consolidate examples - Reduce from 1840 to 817 lines (55% smaller) - Move all scattered example references to Examples Reference table at end - Add missing setHeader() API documentation - Add all 50 examples organized by category (Tools, Commands, Events, UI, etc.) - Fix tui.md hooks->extensions terminology --- packages/coding-agent/docs/extensions.md | 1774 +++++----------------- 1 file changed, 376 insertions(+), 1398 deletions(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 82ed5d68..a4f47a3b 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -8,23 +8,12 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe - **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 UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` - **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. +See [Examples Reference](#examples-reference) for working implementations. ## Table of Contents @@ -32,12 +21,7 @@ See [examples/extensions/](../examples/extensions/) for working implementations. - [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) @@ -46,6 +30,7 @@ See [examples/extensions/](../examples/extensions/) for working implementations. - [Custom UI](#custom-ui) - [Error Handling](#error-handling) - [Mode Behavior](#mode-behavior) +- [Examples Reference](#examples-reference) ## Quick Start @@ -115,38 +100,32 @@ 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" - ] + "packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"], + "extensions": ["/path/to/extension.ts"] } ``` -Use `pi install`, `pi remove`, `pi list`, and `pi update` to manage packages: +Manage packages with CLI: ```bash pi install npm:@foo/bar@1.0.0 pi install git:github.com/user/repo@v1 -pi install https://github.com/user/repo # raw URLs work too +pi install https://github.com/user/repo pi remove npm:@foo/bar -pi list # show installed packages -pi update # update all non-pinned packages +pi list +pi update ``` -**Package filtering:** By default, packages load all resources (extensions, skills, prompts, themes). To selectively load only certain resources: +**Package filtering:** Selectively load resources: ```json { "packages": [ "npm:simple-pkg", { - "source": "npm:shitty-extensions", - "extensions": ["extensions/oracle.ts", "extensions/memory-mode.ts"], - "skills": ["skills/a-nach-b"], + "source": "npm:my-extensions", + "extensions": ["extensions/oracle.ts"], + "skills": [], "themes": [], "prompts": [] } @@ -154,112 +133,34 @@ pi update # update all non-pinned packages } ``` -- Omit a key to load all of that type from the package -- Use empty array `[]` to load none of that type -- Paths are relative to package root -- Use `!pattern` to exclude (e.g., `"themes": ["!funky.json"]`) -- Glob patterns supported via minimatch (e.g., `"extensions": ["**/*.ts", "!**/deprecated/*"]`) -- **Layered filtering:** User filters apply on top of manifest filters. If a manifest excludes 10 extensions and user adds one more exclusion, all 11 are excluded. +- Omit key = load all, empty array = load none +- Glob patterns and `!exclusions` supported +- User filters layer on top of manifest filters -**Package deduplication:** If the same package appears in both global (`~/.pi/agent/settings.json`) and project (`.pi/settings.json`) settings, the project version wins and the global one is ignored. This prevents duplicate resource collisions when you have the same package installed at both scopes. Package identity is determined by: -- **npm packages:** Package name (e.g., `npm:foo` and `npm:foo@1.0.0` are the same identity) -- **git packages:** Repository URL without ref (e.g., `git:github.com/user/repo` and `git:github.com/user/repo@v1` are the same identity) -- **local paths:** Resolved absolute path +**Package deduplication:** If same package in global and project settings, project wins. **Discovery rules:** -1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly -2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension -3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"pi"` field → loads declared paths - -``` -~/.pi/agent/extensions/ -├── simple.ts # Direct file (auto-discovered) -├── my-tool/ -│ └── index.ts # Subdirectory with index (auto-discovered) -└── my-extension-pack/ - ├── package.json # Declares multiple extensions - ├── node_modules/ # Dependencies installed here - └── src/ - ├── safety-gates.ts # First extension - └── custom-tools.ts # Second extension -``` +1. Direct files: `extensions/*.ts` → loaded directly +2. Subdirectory with index: `extensions/myext/index.ts` → single extension +3. Subdirectory with package.json: `extensions/myext/package.json` with `"pi"` field → loads declared paths ```json -// my-extension-pack/package.json +// package.json with pi manifest { "name": "my-extension-pack", - "dependencies": { - "zod": "^3.0.0" - }, + "keywords": ["pi-package"], + "dependencies": { "zod": "^3.0.0" }, "pi": { - "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"], + "extensions": ["./src/index.ts"], "skills": ["./skills/"], "prompts": ["./prompts/"], - "themes": ["./themes/dark.json"] + "themes": ["./themes/"] } } ``` -The pi manifest supports glob patterns and exclusions: - -```json -{ - "pi": { - "extensions": ["./extensions", "!**/deprecated/*"], - "skills": ["./skills", "!**/experimental/*"], - "themes": ["./themes/*.json"] - } -} -``` - -**Bundling other pi packages:** - -To include resources from another pi package, add it as a dependency with `bundledDependencies` to ensure it's embedded in your published tarball: - -```json -{ - "name": "my-extension-pack", - "dependencies": { - "other-pi-package": "^1.0.0" - }, - "bundledDependencies": [ - "other-pi-package" - ], - "pi": { - "extensions": [ - "./extensions", - "./node_modules/other-pi-package/extensions", - "!**/unwanted-extension.ts" - ], - "skills": [ - "./skills", - "./node_modules/other-pi-package/skills" - ] - } -} -``` - -`bundledDependencies` embeds the package inside your tarball, preserving the `node_modules/` structure. Without it, npm's hoisting could move the dependency elsewhere, breaking the paths. - -**Discoverability:** Add the `pi-package` keyword to your `package.json` so users can find your package on npm: - -```json -{ - "name": "my-extension-pack", - "keywords": ["pi-package", "pi-extension", "..."], - ... -} -``` - -Search for pi packages: `curl -s "https://registry.npmjs.org/-/v1/search?text=keywords:pi-package"` - -The `package.json` approach enables: -- Multiple extensions from one package -- Skills, prompts, and themes declared alongside extensions -- Third-party npm dependencies (resolved via jiti) -- Nested source structure (no depth limit within the package) -- Deployment to and installation from npm +Run `npm install` in extensions with dependencies. ## Available Imports @@ -270,28 +171,17 @@ The `package.json` approach enables: | `@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. +npm dependencies work if you add `package.json` next to extension. Node.js built-ins available. ## Writing an Extension -An extension exports a default function that receives `ExtensionAPI`: +Export a default function receiving `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.on("event_name", async (event, ctx) => { ... }); pi.registerTool({ ... }); pi.registerCommand("name", { ... }); pi.registerShortcut("ctrl+x", { ... }); @@ -299,116 +189,43 @@ export default function (pi: ExtensionAPI) { } ``` -Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. +Extensions loaded via [jiti](https://github.com/unjs/jiti), 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. +**Styles:** Single file, directory with `index.ts`, or package with `package.json` for npm dependencies. ## 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 ◄────────────────────────────────┘ +pi starts → session_start -/new (new session) or /resume (switch session) - ├─► session_before_switch (can cancel) - └─► session_switch +user sends prompt + ├─► (extension commands bypass if found) + ├─► input (can intercept/transform) + ├─► (skill/template expansion) + ├─► before_agent_start (inject message, modify system prompt) + ├─► agent_start + │ ┌─── turn (repeats while LLM calls tools) ───┐ + │ ├─► turn_start + │ ├─► context (modify messages) + │ │ LLM responds: + │ │ ├─► tool_call (can block) + │ │ └─► tool_result (can modify) + │ └─► turn_end + └─► agent_end -/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 +/new, /resume → session_before_switch → session_switch +/fork → session_before_fork → session_fork +/compact → session_before_compact → session_compact +/tree → session_before_tree → session_tree +/model, Ctrl+P → model_select +exit → session_shutdown ``` ### Session Events #### session_start - Fired on initial session load. ```typescript @@ -417,474 +234,362 @@ pi.on("session_start", async (_event, ctx) => { }); ``` -**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [file-trigger.ts](../examples/extensions/file-trigger.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) - #### session_before_switch / session_switch - -Fired when starting a new session (`/new`) or switching sessions (`/resume`). +Fired on `/new` or `/resume`. Can cancel. ```typescript pi.on("session_before_switch", async (event, ctx) => { - // event.reason - "new" or "resume" - // event.targetSessionFile - session we're switching to (only for "resume") - + // event.reason: "new" | "resume" + // event.targetSessionFile (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 -}); ``` -**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts) - #### session_before_fork / session_fork - -Fired when forking via `/fork`. +Fired on `/fork`. Can cancel or skip conversation restore. ```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 + // event.entryId + return { cancel: true }; + // OR: return { skipConversationRestore: true }; }); ``` -**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) - #### session_before_compact / session_compact - -Fired on compaction. See [compaction.md](compaction.md) for details. +Fired on compaction. Can cancel or provide custom summary. See [compaction.md](compaction.md). ```typescript pi.on("session_before_compact", async (event, ctx) => { - const { preparation, branchEntries, customInstructions, signal } = event; - - // Cancel: + // event.preparation, event.branchEntries, event.customInstructions, event.signal 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 + // OR: return { compaction: { summary: "...", firstKeptEntryId: "...", tokensBefore: 0 } }; }); ``` -**Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts), [trigger-compact.ts](../examples/extensions/trigger-compact.ts) - #### session_before_tree / session_tree - -Fired on `/tree` navigation. +Fired on `/tree` navigation. Can cancel or provide custom summary. ```typescript pi.on("session_before_tree", async (event, ctx) => { - const { preparation, signal } = event; + // event.preparation, event.signal return { cancel: true }; - // OR provide custom summary: - return { summary: { summary: "...", details: {} } }; -}); - -pi.on("session_tree", async (event, ctx) => { - // event.newLeafId, oldLeafId, summaryEntry, fromExtension + // OR: return { summary: { summary: "...", details: {} } }; }); ``` -**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) - #### session_shutdown - Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). ```typescript pi.on("session_shutdown", async (_event, ctx) => { - // Cleanup, save state, etc. + // Cleanup, save state }); ``` -**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts) - ### Agent Events #### before_agent_start - -Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt. +Fired after user submits prompt, before agent loop. Can inject message and/or modify 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 - + // event.prompt, event.images, event.systemPrompt 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...", + message: { customType: "my-ext", content: "Context", display: true }, + systemPrompt: event.systemPrompt + "\n\nExtra instructions...", }; }); ``` -**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts) - #### 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 }); ``` -**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - #### 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 }); ``` -**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [status-line.ts](../examples/extensions/status-line.ts) - #### context - Fired before each LLM call. Modify messages non-destructively. ```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 }; + return { messages: event.messages.filter(m => !shouldPrune(m)) }; }); ``` -**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - ### Model Events #### model_select - -Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. +Fired when model changes via `/model`, 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"); + // event.model, event.previousModel, event.source ("set" | "cycle" | "restore") }); ``` -Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. - -**Examples:** [model-status.ts](../examples/extensions/model-status.ts) - ### Tool Events #### tool_call - Fired before tool executes. **Can block.** ```typescript pi.on("tool_call", async (event, ctx) => { - // event.toolName - "bash", "read", "write", "edit", etc. - // event.toolCallId - // event.input - tool parameters - - if (shouldBlock(event)) { - return { block: true, reason: "Not allowed" }; - } + // event.toolName, event.toolCallId, event.input + if (shouldBlock(event)) return { block: true, reason: "Not allowed" }; }); ``` -**Examples:** [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts) - #### tool_result - Fired after tool executes. **Can modify result.** ```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: + // event.toolName, event.toolCallId, event.input, event.content, event.details, event.isError + if (isBashToolResult(event)) { /* event.details typed as BashToolDetails */ } return { content: [...], details: {...}, isError: false }; }); ``` -**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) +Type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult` ### User Bash Events #### user_bash - -Fired when user executes `!` or `!!` commands. **Can intercept.** +Fired on `!` 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) + // event.command, event.excludeFromContext, event.cwd return { operations: remoteBashOps }; - - // Option 2: Full replacement - return result directly - return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; + // OR: return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; }); ``` -**Examples:** [ssh.ts](../examples/extensions/ssh.ts), [interactive-shell.ts](../examples/extensions/interactive-shell.ts) - ### 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.) +Fired after extension commands checked, before skill/template expansion. ```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 + // event.text, event.images, event.source ("interactive" | "rpc" | "extension") 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 + return { action: "continue" }; }); ``` -**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). +Results: `continue` (default), `transform`, `handled` ## 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: - -```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"); - }, -}); -``` +| Property/Method | Description | +|-----------------|-------------| +| `ui` | UI methods. See [Custom UI](#custom-ui) | +| `hasUI` | `false` in print/RPC mode | +| `cwd` | Current working directory | +| `sessionManager` | Read-only: `getEntries()`, `getBranch()`, `getLeafId()` | +| `modelRegistry` | Model and API key access | +| `model` | Current model (may be undefined) | +| `isIdle()` | Whether agent is idle | +| `abort()` | Abort current operation | +| `hasPendingMessages()` | Whether messages are queued | +| `shutdown()` | Request graceful exit | +| `getContextUsage()` | Returns `{ tokens, contextWindow, percent, ... }` | +| `compact(options?)` | Trigger compaction with `onComplete`/`onError` callbacks | ## 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. +Command handlers get `ExtensionCommandContext` (extends `ExtensionContext`): -### 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) +| Method | Description | +|--------|-------------| +| `waitForIdle()` | Wait for agent to finish streaming | +| `newSession(options?)` | Create new session with optional `parentSession` and `setup` callback | +| `fork(entryId)` | Fork from entry, creating new session file | +| `navigateTree(targetId, options?)` | Navigate tree with `summarize`, `customInstructions`, `replaceInstructions`, `label` | ## ExtensionAPI Methods -### pi.on(event, handler) +### Event Subscription -Subscribe to events. See [Events](#events) for event types and return values. +```typescript +pi.on(event, handler) // See Events section +``` -### pi.registerTool(definition) +### Tool Registration -Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. +```typescript +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does", + parameters: Type.Object({ ... }), + async execute(toolCallId, params, onUpdate, ctx, signal) { ... }, + renderCall?(args, theme) { ... }, + renderResult?(result, options, theme) { ... }, +}) +``` + +### Message Injection + +```typescript +// Custom message +pi.sendMessage({ customType: "my-ext", content: "...", display: true, details: {} }, { + triggerTurn: true, + deliverAs: "steer" | "followUp" | "nextTurn" +}); + +// User message (always triggers turn) +pi.sendUserMessage("text" | [{ type: "text", text: "..." }], { + deliverAs: "steer" | "followUp" // required when streaming +}); +``` + +### State Persistence + +```typescript +pi.appendEntry("my-state", { count: 42 }); // Does NOT go to LLM +``` + +### Session Metadata + +```typescript +pi.setSessionName(name) +pi.getSessionName() +pi.setLabel(entryId, label) +``` + +### Command Registration + +```typescript +pi.registerCommand("name", { + description: "...", + getArgumentCompletions?: (prefix) => AutocompleteItem[] | null, + handler: async (args, ctx) => { ... } +}); +``` + +### Message Rendering + +```typescript +pi.registerMessageRenderer("customType", (message, { expanded }, theme) => Component | undefined); +``` + +### Shortcuts and Flags + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "...", + handler: async (ctx) => { ... } +}); + +pi.registerFlag("plan", { description: "...", type: "boolean", default: false }); +pi.getFlag("--plan") +``` + +### Shell Execution + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +### Tool Management + +```typescript +pi.getActiveTools() // ["read", "bash", "edit", "write"] +pi.getAllTools() // [{ name, description }, ...] +pi.setActiveTools(names) +``` + +### Model and Thinking + +```typescript +await pi.setModel(model) // Returns false if no API key +pi.getThinkingLevel() // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel(level) +``` + +### Provider Registration + +```typescript +pi.registerProvider("my-proxy", { + baseUrl: "https://proxy.example.com", + apiKey: "PROXY_API_KEY", + api: "anthropic-messages", + headers?: { ... }, + authHeader?: true, + models?: [{ id, name, reasoning, input, cost, contextWindow, maxTokens, compat? }], + oauth?: { name, login, refreshToken, getApiKey, modifyModels? }, + streamSimple?: (model, context, options) => AssistantMessageEventStream +}); +``` + +See [custom-provider.md](custom-provider.md) for details. + +### Event Bus + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +## State Management + +Store state in tool result `details` for proper branching: + +```typescript +let items: string[] = []; + +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(...) { + items.push("new"); + return { content: [...], details: { items: [...items] } }; + }, +}); +``` + +## Custom Tools + +### Tool Definition ```typescript import { Type } from "@sinclair/typebox"; @@ -894,939 +599,147 @@ 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, onUpdate, ctx, signal) { - // 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) { ... }, -}); -``` - -**Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts) - -### 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"`). - -**Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - -### 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 - } - } -}); -``` - -**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts) - -### 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"); - }, -}); -``` - -**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) - -### 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. - -```typescript -pi.registerShortcut("ctrl+shift+p", { - description: "Toggle plan mode", - handler: async (ctx) => { - ctx.ui.notify("Toggled!"); - }, -}); -``` - -**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts) - -### 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 -} -``` - -**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts) - -### 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 -``` - -**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts) - -### 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 -``` - -**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts) - -### pi.setModel(model) - -Set the current model. Returns `false` if no API key is available for the model. - -```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"); - } -} -``` - -**Examples:** [preset.ts](../examples/extensions/preset.ts) - -### 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"); -``` - -**Examples:** [preset.ts](../examples/extensions/preset.ts) - -### 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, onUpdate, ctx, signal) { - 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. - -### 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, onUpdate, ctx, signal) { - // 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 - }; + if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] }; + onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); + return { content: [{ type: "text", text: "Done" }], details: {} }; }, - - // Optional: Custom rendering renderCall(args, theme) { ... }, - renderResult(result, options, theme) { ... }, + renderResult(result, { expanded, isPartial }, 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. +Register tool with same name (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`). Built-in renderer used if no custom render functions. Must match exact result shape including `details` type. -```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` +Use `--no-tools -e ./my-extension.ts` to start with only extension tools. ### Remote Execution -Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): +Built-in tools support pluggable operations: ```typescript -import { createReadTool, createBashTool, type ReadOperations } from "@mariozechner/pi-coding-agent"; +import { createReadTool, 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, onUpdate, _ctx, signal) { - 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` - -See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. +Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` ### 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: +Tools MUST truncate output. Built-in limit: 50KB / 2000 lines. ```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) - formatSize, // Human-readable size (e.g., "50KB", "1.5MB") - DEFAULT_MAX_BYTES, // 50KB - DEFAULT_MAX_LINES, // 2000 -} from "@mariozechner/pi-coding-agent"; +import { truncateHead, truncateTail, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; -async execute(toolCallId, params, onUpdate, ctx, signal) { - 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(); - }); +const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); +if (truncation.truncated) { + // Write full to temp file, inform LLM } ``` ### Custom Rendering -Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API. - -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 + return new Text(theme.fg("toolTitle", "my_tool ") + args.action, 0, 0); } -``` -#### 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) + if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0); let text = theme.fg("success", "✓ Done"); if (expanded && result.details?.items) { - for (const item of result.details.items) { - text += "\n " + theme.fg("dim", item); - } + 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` +Use `keyHint(action, description)` for keybinding hints. ## 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) +See [tui.md](tui.md) for full component API. ### 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 choice = await ctx.ui.select("Pick:", ["A", "B", "C"]); +const ok = await ctx.ui.confirm("Delete?", "Cannot be undone"); 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" +const text = await ctx.ui.editor("Edit:", "prefill"); +ctx.ui.notify("Done!", "info" | "warning" | "error"); ``` -**Examples:** -- `ctx.ui.select()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts) -- `ctx.ui.confirm()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts) -- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts) -- `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts) - -#### Timed Dialogs with Countdown - -Dialogs support a `timeout` option that auto-dismisses with a live countdown display: +Dialogs support `timeout` (auto-dismiss with countdown) and `signal` (manual abort): ```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 -} +const ok = await ctx.ui.confirm("Title", "Message", { timeout: 5000 }); ``` -**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`: +### Widgets, Status, Footer, Header ```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") -} +ctx.ui.setStatus("key", "text" | undefined); +ctx.ui.setWorkingMessage("Custom loading..." | undefined); +ctx.ui.setWidget("key", ["Line 1", "Line 2"], { placement: "aboveEditor" | "belowEditor" }); +ctx.ui.setWidget("key", (tui, theme) => Component); +ctx.ui.setFooter((tui, theme, footerData) => Component | undefined); +ctx.ui.setHeader((tui, theme) => Component | undefined); +ctx.ui.setTitle("Window title"); ``` -See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples. - -### Widgets, Status, and Footer +### Editor ```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(); - -// 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 -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 +ctx.ui.setEditorText("prefill"); +ctx.ui.getEditorText(); +ctx.ui.setEditorComponent((tui, theme, keybindings) => EditorComponent | undefined); ``` -**Examples:** -- `ctx.ui.setStatus()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts) -- `ctx.ui.setWidget()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) -- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts) -- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts) -- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts) -- `ctx.ui.setTheme()`: [mac-system-theme.ts](../examples/extensions/mac-system-theme.ts) +### Theme + +```typescript +ctx.ui.theme.fg("accent", "text") +ctx.ui.getAllThemes() +ctx.ui.getTheme("light") +ctx.ui.setTheme("light" | themeObject) +``` ### 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 -} +const result = await ctx.ui.custom((tui, theme, keybindings, done) => { + return { render(width) { ... }, handleInput(data) { ... }, invalidate() { ... } }; +}, { overlay?: true, overlayOptions?: { anchor, width, margin, ... }, onHandle?: (handle) => {} }); ``` -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. - -**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts) - -### 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. - -**Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts) - ### 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); +pi.registerMessageRenderer("my-ext", (message, { expanded }, theme) => { + return new Text(theme.fg("accent", message.content), 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: - -```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) -``` - ## Error Handling -- Extension errors are logged, agent continues +- Extension errors logged, agent continues - `tool_call` errors block the tool (fail-safe) -- Tool `execute` errors are reported to the LLM with `isError: true` +- Tool `execute` errors reported to LLM with `isError: true` ## Mode Behavior @@ -1834,6 +747,71 @@ theme.strikethrough(text) |------|-----------|-------| | Interactive | Full TUI | Normal operation | | RPC | JSON protocol | Host handles UI | -| Print (`-p`) | No-op | Extensions run but can't prompt | +| Print (`-p`) | No-op | Check `ctx.hasUI` | -In print mode, 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` | +| `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` | +| `tool-override.ts` | Override built-in read tool | `registerTool` | +| **Commands** | +| `pirate.ts` | Modify system prompt | `registerCommand`, `before_agent_start` | +| `summarize.ts` | Conversation summary | `registerCommand`, `ui.custom` | +| `handoff.ts` | Cross-provider handoff | `registerCommand`, `ui.editor` | +| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom` | +| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` | +| `shutdown-command.ts` | Graceful shutdown | `registerCommand`, `shutdown()` | +| **Events & Gates** | +| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | +| `protected-paths.ts` | Block writes to paths | `on("tool_call")` | +| `confirm-destructive.ts` | Confirm session changes | `on("session_before_*")` | +| `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")` | +| **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")`, `exec` | +| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | +| **UI Components** | +| `status-line.ts` | Footer status indicator | `setStatus` | +| `custom-footer.ts` | Replace footer | `setFooter` | +| `custom-header.ts` | Replace startup header | `setHeader` | +| `modal-editor.ts` | Vim-style editor | `setEditorComponent` | +| `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` | +| `widget-placement.ts` | Widget positioning | `setWidget` | +| `overlay-test.ts` | Overlay components | `ui.custom`, 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`, timeout/signal | +| **Complex Extensions** | +| `plan-mode/` | Full plan mode implementation | All APIs | +| `preset.ts` | Saveable presets (model, tools) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools` | +| `tools.ts` | Toggle tools on/off | `registerCommand`, `setActiveTools`, `SettingsList` | +| `claude-rules.ts` | Load rules from files | `on("before_agent_start")` | +| `file-trigger.ts` | File watcher triggers | `sendMessage` | +| **Remote & Sandbox** | +| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, tool operations | +| `interactive-shell.ts` | Persistent shell | `on("user_bash")` | +| `sandbox/` | Sandboxed execution | Tool operations | +| `subagent/` | Spawn sub-agents | `exec`, tool registration | +| **Games & Fun** | +| `snake.ts` | Snake game | `registerCommand`, `ui.custom` | +| `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` | +| `doom-overlay/` | Doom in overlay | `ui.custom`, overlay | +| **Providers** | +| `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` | +| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider`, OAuth | +| **Misc** | +| `mac-system-theme.ts` | Auto-switch theme | `setTheme` | +| `antigravity-image-gen.ts` | Image generation | `registerTool`, Google Antigravity | +| `inline-bash.ts` | Inline bash execution | `on("tool_call")` | +| `with-deps/` | Extension with npm deps | Package structure |