Files
oh-my-pi/docs/extensions.md
T
can1357 64fcdc308f refactor(coding-agent)!: removed StringEnum helper and shortened tool schema descriptions
- Replaced all StringEnum(...) usages with z.enum([...]) across tools, examples, and tests.
- Removed StringEnum re-export from @oh-my-pi/pi-coding-agent public API.
- Condensed verbose tool parameter descriptions to minimal lowercase phrases.
- Renamed AuthCredentialStore to SqliteAuthCredentialStore at usage sites.
2026-05-16 19:26:32 +02:00

11 KiB

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 docs/extension-loading.md.

What an extension is

An extension is a TS/JS module exporting a default factory:

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

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
  • sendMessage, sendUserMessage, appendEntry
  • getActiveTools, getAllTools, setActiveTools
  • 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.zod (injected zod module — use 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 (nextTurn ignores this)

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
  • getContextUsage()
  • compact(...)
  • isIdle(), hasPendingMessages(), abort()
  • shutdown()
  • getSystemPrompt()

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
  • context
  • agent_start / agent_end
  • 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_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

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:

execute(
	toolCallId,
	params,
	signal,
	onUpdate,
	ctx,
): Promise<AgentToolResult>

Template:

const { z } = pi.zod;

pi.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "...",
  parameters: z.object({}),
  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.

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
  • 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

Also note: setWidget currently routes to status-line text via setHookWidget(...).

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, setTitle, setEditorText)

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.

Background interactive mode

Background mode installs a non-interactive UI context object. In current implementation, ctx.hasUI may still be true while interactive dialogs return defaults/no-op behavior.

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:

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

pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
  // return pi-tui Component
});

Used by interactive rendering when custom messages are displayed.

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