# Extensions Primary guide for authoring runtime extensions in `packages/coding-agent`. This document covers the current extension runtime in: - `src/extensibility/extensions/types.ts` - `src/extensibility/extensions/runner.ts` - `src/extensibility/extensions/wrapper.ts` - `src/extensibility/extensions/index.ts` - `src/modes/controllers/extension-ui-controller.ts` For discovery paths and filesystem loading rules, see [`extension-loading.md`](./extension-loading.md). ## What an extension is An extension is a TS/JS module exporting a default factory: ```ts import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function myExtension(pi: ExtensionAPI) { // register handlers/tools/commands/renderers } ``` Extensions can combine all of the following in one module: - event handlers (`pi.on(...)`) - LLM-callable tools (`pi.registerTool(...)`) - slash commands (`pi.registerCommand(...)`) - keyboard shortcuts and flags - custom message rendering - session/message injection APIs (`sendMessage`, `sendUserMessage`, `appendEntry`) ## Runtime model 1. Extensions are imported and their factory functions run. 2. During that load phase, registration methods are valid; runtime action methods are not yet initialized. 3. `ExtensionRunner.initialize(...)` wires live actions/contexts for the active mode. 4. Session/agent/tool lifecycle events are emitted to handlers. 5. Every tool execution is wrapped with extension interception (`tool_call` / `tool_result`). ```text Extension lifecycle (simplified) load paths │ ▼ import module + run factory (registration only) │ ▼ ExtensionRunner.initialize(mode/session/tool registry) │ ├─ emit session/agent events to handlers ├─ wrap tool execution (tool_call/tool_result) └─ expose runtime actions (sendMessage, setActiveTools, ...) ``` Important constraint from `loader.ts`: - calling action methods like `pi.sendMessage()` during extension load throws `ExtensionRuntimeNotInitializedError` - register first; perform runtime behavior from events/commands/tools ## Quick start ```ts import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function (pi: ExtensionAPI) { const { z } = pi.zod; pi.setLabel("Safety + Utilities"); pi.on("session_start", async (_event, ctx) => { ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info"); }); pi.on("tool_call", async (event) => { if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { return { block: true, reason: "Blocked by extension policy" }; } }); pi.registerTool({ name: "hello_extension", label: "Hello Extension", description: "Return a greeting", parameters: z.object({ name: z.string() }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { return { content: [{ type: "text", text: `Hello, ${params.name}` }], details: { greeted: params.name }, }; }, }); pi.registerCommand("hello-ext", { description: "Show queue state", handler: async (_args, ctx) => { ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info"); }, }); } ``` ## Extension API surfaces ## 1) Registration and actions (`ExtensionAPI`) Core methods: - `on(event, handler)` - `registerTool`, `registerCommand`, `registerShortcut`, `registerFlag` - `registerMessageRenderer`, `registerAssistantThinkingRenderer` - `setLabel`, `getFlag` - `sendMessage`, `sendUserMessage`, `appendEntry`, `exec` - `getActiveTools`, `getAllTools`, `setActiveTools` - `getCommands` - `getSessionName`, `setSessionName` - `setModel`, `getThinkingLevel`, `setThinkingLevel` - `registerProvider` - `events` (shared event bus) In interactive mode, `input` handlers run before the built-in first-message auto-title check. Extensions that call `await pi.setSessionName(...)` from `input` can set the persisted session name and prevent the default auto-generated title from running for that session. Also exposed: - `pi.logger` - `pi.typebox` (zod-backed compatibility shim for legacy TypeBox-style schemas) - `pi.zod` (injected `zod/v4` module — canonical for tool parameter schemas) - `pi.pi` (package exports) ### Message delivery semantics `pi.sendMessage(message, options)` supports: - `deliverAs: "steer"` (default) — interrupts current run - `deliverAs: "followUp"` — queued to run after current run - `deliverAs: "nextTurn"` — stored and injected on the next user prompt - `triggerTurn: true` — starts a turn when idle (also honored with `deliverAs: "nextTurn"`: idle prompts immediately; while streaming the queued message schedules an internal continuation) `pi.sendUserMessage(content, { deliverAs })` always goes through prompt flow; while streaming it queues as steer/follow-up. ## 2) Handler context (`ExtensionContext`) Handlers and tool `execute` receive `ctx` with: - `ui` - `hasUI` - `cwd` - `sessionManager` (read-only) - `modelRegistry`, `model` - `models` (read-only model query — see below) - `getContextUsage()` - `compact(...)` - `isIdle()`, `hasPendingMessages()`, `abort()` - `shutdown()` - `getSystemPrompt()` - `memory` (optional structured memory runtime — status/search/save across the configured backend) ### Model selection (`ctx.models`) `ctx.models` is a read-only facade for picking and comparing models the same way core does: - `list()` — authenticated models available this session. - `current()` — the live session model (read lazily, so it reflects `/model` switches). - `resolve(spec)` — a model string (`provider/id`, bare id) or role alias (`pi/slow`, a configured role) → `Model`, honoring the same settings-backed aliases and match preferences as `--model`. Returns `undefined` when nothing matches. - `family(model)` — an opaque lineage token for "same family?" checks (Claude point releases share a token; Claude and GPT differ). Compare it; don't persist it (the vocabulary tracks new releases). ```ts // Pick a model from a different family than the current one (e.g. a cross-family reviewer). const current = ctx.models.current(); const contrasting = ctx.models .list() .find(m => current && ctx.models.family(m) !== ctx.models.family(current)); ``` ## 3) Command context (`ExtensionCommandContext`) Command handlers additionally get: - `waitForIdle()` - `newSession(...)` - `switchSession(...)` - `branch(entryId)` - `navigateTree(targetId, { summarize })` - `reload()` Use command context for session-control flows; these methods are intentionally separated from general event handlers. ## Event surface (current names and behavior) Canonical event unions and payload types are in `types.ts`. ### Session lifecycle - `session_start` - `session_before_switch` / `session_switch` - `session_before_branch` / `session_branch` - `session_before_compact` / `session.compacting` / `session_compact` - `session_before_tree` / `session_tree` - `session_shutdown` Cancelable pre-events: - `session_before_switch` → `{ cancel?: boolean }` - `session_before_branch` → `{ cancel?: boolean; skipConversationRestore?: boolean }` - `session_before_compact` → `{ cancel?: boolean; compaction?: CompactionResult }` - `session_before_tree` → `{ cancel?: boolean; summary?: { summary: string; details?: unknown } }` ### Prompt and turn lifecycle - `input` - `before_agent_start` - `before_provider_request` (may replace provider request payload) - `after_provider_response` - `context` - `agent_start` / `agent_end` — agent loop lifecycle notification; `agent_end` remains notification-only - `session_stop` — main-session stop hook, awaited before settle; may continue with `{ continue: true, additionalContext }` or `{ decision: "block", reason }`; capped at 8 consecutive continuations and never fires for task/subagent sessions - `turn_start` / `turn_end` - `message_start` / `message_update` / `message_end` ### Tool lifecycle - `tool_call` (pre-exec, may block) - `tool_result` (post-exec, may patch content/details/isError) - `tool_execution_start` / `tool_execution_update` / `tool_execution_end` (observability) - `tool_approval_requested` / `tool_approval_resolved` (observability; emitted by `wrapper.ts` only when a tool requires approval and an approval handler is registered) `tool_result` is middleware-style: handlers run in extension order and each sees prior modifications. ### Reliability/runtime signals - `auto_compaction_start` / `auto_compaction_end` - `auto_retry_start` / `auto_retry_end` - `ttsr_triggered` - `todo_reminder` - `goal_updated` - `credential_disabled` ### User command interception - `user_bash` (override with `{ result }`) - `user_python` (override with `{ result }`) ### `resources_discover` `resources_discover` exists in extension types and `ExtensionRunner`. Current runtime note: `ExtensionRunner.emitResourcesDiscover(...)` is implemented, but there are no `AgentSession` callsites invoking it in the current codebase. ## Tool authoring details `registerTool` uses `ToolDefinition` from `types.ts`. Current `execute` signature: ```ts execute( toolCallId, params, signal, onUpdate, ctx, ): Promise ``` Template: ```ts const { z } = pi.zod; pi.registerTool({ name: "my_tool", label: "My Tool", description: "...", parameters: z.object({}), hidden: false, defaultInactive: false, deferrable: false, async execute(_id, _params, signal, onUpdate, ctx) { if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; } onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); return { content: [{ type: "text", text: "Done" }], details: {} }; }, onSession(event, ctx) { // reason: start|switch|branch|tree|shutdown }, renderCall(args, options, theme) { // optional TUI render }, renderResult(result, options, theme, args) { // optional TUI render }, }); ``` `tool_call`/`tool_result` intercept all tools once the registry is wrapped in `sdk.ts`, including built-ins and extension/custom tools. `ToolDefinition` also supports optional `hidden`, `defaultInactive`, `deferrable`, `approval`, `mcpServerName`, `mcpToolName`, `renderCall`, and `renderResult` fields. ## UI integration points `ctx.ui` implements the `ExtensionUIContext` interface. Support differs by mode. ### Interactive mode (`extension-ui-controller.ts`) Supported: - dialogs: `select`, `confirm`, `input`, `editor` - input editing: `setEditorText`, `getEditorText`, `pasteToEditor`, `editor` - terminal title and working message (`setTitle`, `setWorkingMessage`) - notifications/status/editor text/terminal input/custom overlays - theme listing/loading by name (`setTheme` supports string names) - tools expanded toggle Current no-op methods in this controller: - `setFooter` - `setHeader` `setEditorComponent` is wired to the live editor (`ctx.setEditorComponent(factory)`). `setWidget` renders real widget components above or below the editor via `setHookWidget(...)` (`placement: "aboveEditor" | "belowEditor"`; string-array content capped at 10 lines). ### RPC mode (`rpc-mode.ts`) `ctx.ui` is backed by RPC `extension_ui_request` events: - dialog methods (`select`, `confirm`, `input`, `editor`) round-trip to client responses - fire-and-forget methods emit requests (`notify`, `setStatus`, `setWidget` for string arrays, `setEditorText`; `setTitle` emits only when `PI_RPC_EMIT_TITLE=1`) Unsupported/no-op in RPC implementation: - `onTerminalInput` - `custom` - `setFooter`, `setHeader`, `setEditorComponent` - `setWorkingMessage` - theme switching/loading (`setTheme` returns failure) - tool expansion controls are inert ### Print/headless/subagent paths When no UI context is supplied to runner init, `ctx.hasUI` is `false` and methods are no-op/default-returning. ### ACP mode ACP installs an elicitation-bridged UI context (`createAcpExtensionUiContext` in `acp-agent.ts`). `ctx.hasUI` is `true` while only `select`/`confirm`/`input` round-trip (as ACP elicitations; defaults are returned when the client lacks the `elicitation.form` capability). The non-elicitation surface (widgets, editor, theming, terminal input) is stubbed no-op. ## Session and state patterns For durable extension state: 1. Persist with `pi.appendEntry(customType, data)`. 2. Rebuild state from `ctx.sessionManager.getBranch()` on `session_start`, `session_branch`, `session_tree`. 3. Keep tool result `details` structured when state should be visible/reconstructible from tool result history. Example reconstruction pattern: ```ts pi.on("session_start", async (_event, ctx) => { let latest; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === "my-state") { latest = entry.data; } } // restore from latest }); ``` ## Rendering extension points ## Custom message renderer ```ts pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => { // return pi-tui Component }); ``` Used by interactive rendering when custom messages are displayed. ## Assistant thinking renderer ```ts import { Container, Text } from "@oh-my-pi/pi-tui"; pi.registerAssistantThinkingRenderer((context, theme) => { const container = new Container(); container.addChild(new Text(theme.fg("dim", `thinking chars: ${context.text.length}`), 1, 0)); return container; }); ``` Used by interactive rendering to add display-only supplemental UI below each visible assistant thinking block. The renderer receives the already-visible thinking text, content/thinking indexes, theme, and a `requestRender()` callback for async renderers. All registered renderers that return a component are appended in registration order. Renderers must not mutate messages; the original thinking block remains the provider/session source of truth. ## Tool call/result renderer Provide `renderCall` / `renderResult` on `registerTool` definitions for custom tool visualization in TUI. ## Constraints and pitfalls - Runtime actions are unavailable during extension load. - `tool_call` errors block execution (fail-closed). - Command name conflicts with built-ins are skipped with diagnostics. - Reserved shortcuts are ignored (`ctrl+c`, `ctrl+d`, `ctrl+z`, `ctrl+k`, `ctrl+p`, `ctrl+l`, `ctrl+o`, `ctrl+t`, `ctrl+g`, `ctrl+q`, `alt+m`, `shift+tab`, `shift+ctrl+p`, `alt+enter`, `escape`, `enter`). - Treat `ctx.reload()` as terminal for the current command handler frame. ## Extensions vs hooks vs custom-tools Use the right surface: - **Extensions** (`src/extensibility/extensions/*`): unified system (events + tools + commands + renderers + provider registration). - **Hooks** (`src/extensibility/hooks/*`): separate legacy event API. - **Custom-tools** (`src/extensibility/custom-tools/*`): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers. If you need one package that owns policy, tools, command UX, and rendering together, use extensions.