# Extension Model — Providers, Plugins, Skills & Safe-Change Rules

> How omp stays model-agnostic: the 40+ provider adapters in pi-ai, the plugin/marketplace loader, slash-command and skill registration, hook injection, MCP wiring, and the discovery pass that inherits rules from .claude/.cursor/.windsurf on first run. Closes with invariants for adding providers or extensions without breaking the session contract.

- Repository: can1357/oh-my-pi
- GitHub: https://github.com/can1357/oh-my-pi
- Human wiki: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45
- Complete Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/llms-full.txt

## Source Files

- `packages/ai/src/providers`
- `packages/ai/src/models.json`
- `packages/coding-agent/src/extensibility/extensions`
- `packages/coding-agent/src/extensibility/plugins`
- `packages/coding-agent/src/extensibility/slash-commands.ts`
- `packages/coding-agent/src/discovery`
- `packages/coding-agent/src/mcp`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/ai/src/providers/register-builtins.ts](packages/ai/src/providers/register-builtins.ts)
- [packages/ai/src/providers/anthropic.ts](packages/ai/src/providers/anthropic.ts)
- [packages/coding-agent/src/extensibility/extensions/loader.ts](packages/coding-agent/src/extensibility/extensions/loader.ts)
- [packages/coding-agent/src/extensibility/extensions/types.ts](packages/coding-agent/src/extensibility/extensions/types.ts)
- [packages/coding-agent/src/extensibility/plugins/manager.ts](packages/coding-agent/src/extensibility/plugins/manager.ts)
- [packages/coding-agent/src/extensibility/plugins/types.ts](packages/coding-agent/src/extensibility/plugins/types.ts)
- [packages/coding-agent/src/extensibility/slash-commands.ts](packages/coding-agent/src/extensibility/slash-commands.ts)
- [packages/coding-agent/src/extensibility/hooks/loader.ts](packages/coding-agent/src/extensibility/hooks/loader.ts)
- [packages/coding-agent/src/discovery/index.ts](packages/coding-agent/src/discovery/index.ts)
- [packages/coding-agent/src/discovery/claude.ts](packages/coding-agent/src/discovery/claude.ts)
- [packages/coding-agent/src/discovery/cursor.ts](packages/coding-agent/src/discovery/cursor.ts)
- [packages/coding-agent/src/discovery/windsurf.ts](packages/coding-agent/src/discovery/windsurf.ts)
- [packages/coding-agent/src/mcp/manager.ts](packages/coding-agent/src/mcp/manager.ts)
- [packages/coding-agent/src/mcp/loader.ts](packages/coding-agent/src/mcp/loader.ts)
</details>

# Extension Model — Providers, Plugins, Skills & Safe-Change Rules

The extension model is the architectural spine that keeps oh-my-pi (omp) model-agnostic and tool-agnostic at the same time. On the AI side, every language model is reached through a typed provider adapter with a single streaming interface, so swapping Anthropic for Google Vertex or a local Ollama server requires no changes to the agent core. On the agent side, everything a user adds — slash commands, hooks, custom tools, MCP servers, skills, keyboard shortcuts — arrives through a capability registry that any number of discovery providers can populate. The two halves are intentionally decoupled: a provider can register a new AI backend without touching extensibility, and a plugin can add slash commands without knowing anything about the active model.

This page explains the mechanics of each layer, how the discovery pass inherits configuration from `.claude`, `.cursor`, and `.windsurf` directories on startup, and what invariants must be preserved when adding a new provider or extension point.

---

## Provider Adapters in `packages/ai`

### Unified Stream Contract

Every AI backend implements one streaming function with the signature:

```typescript
// packages/ai/src/providers/register-builtins.ts:41-43
interface LazyProviderModule<TApi extends Api> {
  stream: (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AsyncIterable<AssistantMessageEvent>;
}
```

The agent core only calls `stream()`. It never imports provider SDKs directly. This means `@anthropic-ai/sdk`, `openai`, `@google-cloud/vertexai`, and the AWS Bedrock event-stream parser are all hidden behind this boundary.

### Lazy Loading

Provider modules are loaded on first call through a module-level promise cache (`||=` pattern). Each provider registers a named loader:

```typescript
// packages/ai/src/providers/register-builtins.ts:270-276
function loadAnthropicProviderModule(): Promise<LazyProviderModule<"anthropic-messages">> {
  anthropicProviderModulePromise ||= import("./anthropic").then(module => {
    const provider = module as AnthropicProviderModule;
    return { stream: provider.streamAnthropic };
  });
  return anthropicProviderModulePromise;
}
```

The same pattern is applied for every supported backend. The exported lazy wrappers (`streamAnthropic`, `streamGoogle`, `streamBedrock`, etc.) all use `createLazyStream()`, which wraps the deferred module load in an `AssistantMessageEventStream` that starts emitting events immediately — callers never see the difference between an eagerly-loaded and a lazily-loaded provider.

### Idle and First-Event Timeouts

`createLazyStream` wraps the inner iterable with `iterateWithIdleTimeout`, enforcing two watchdog timers:

- `streamFirstEventTimeoutMs` — kicks in before the first non-`start` token arrives (guards against cold proxies and reasoning models with slow TTFT).
- `streamIdleTimeoutMs` — resets on every token; aborts the request if the stream stalls mid-response.

Both timeouts produce an `AssistantMessage` with `stopReason: "error"` or `stopReason: "aborted"`, ensuring the agent can always distinguish caller-initiated cancellation from provider failure.

Sources: [packages/ai/src/providers/register-builtins.ts:160-210]()

### Provider Catalogue

The following provider modules exist in `packages/ai/src/providers/`:

| Module | API key / protocol |
|---|---|
| `anthropic.ts` | Anthropic Messages API (also used for GitHub Copilot via header shim) |
| `openai-responses.ts` | OpenAI Responses API |
| `openai-completions.ts` | OpenAI legacy completions |
| `openai-codex-responses.ts` | OpenAI Codex (sub-dir with request-transformer and response-handler) |
| `azure-openai-responses.ts` | Azure-hosted OpenAI |
| `google.ts` | Google Generative AI |
| `google-vertex.ts` | Google Vertex AI |
| `google-gemini-cli.ts` | Gemini CLI tunnel |
| `amazon-bedrock.ts` | AWS Bedrock via SigV4 + event-stream |
| `cursor.ts` | Cursor agent proto/gRPC |
| `ollama.ts` | Ollama chat |
| `gitlab-duo.ts` | GitLab Duo |
| `kimi.ts` | Moonshot Kimi |
| `pi-native-client.ts` / `pi-native-server.ts` | Internal pi native protocol |
| `mock.ts` | Test/mock provider |

Bedrock uniquely supports an override injection point (`setBedrockProviderModule`) for environments that supply their own AWS credential resolution without bundling the default module.

Sources: [packages/ai/src/providers/register-builtins.ts:137-381]()

---

## Capability Registry and the Discovery Pass

### Capability Architecture

The agent-side extensibility layer is organized around a **capability registry**: named capability types (slash commands, skills, hooks, MCP servers, extension modules, rules, context files, settings, tools, system prompts) and **providers** that populate each type. Providers register themselves by importing a module; the import side-effect calls `registerProvider(capabilityId, { id, displayName, priority, load })`.

`loadCapability<T>(capabilityId, ctx)` calls every registered provider for that capability type, merges the results, and returns a deduplicated list tagged with `_source` metadata (which provider, which file path, and which scope — `user` | `project` | `native`).

Sources: [packages/coding-agent/src/discovery/index.ts:1-62]()

### Discovery Providers Registered at Startup

`packages/coding-agent/src/discovery/index.ts` bootstraps all providers by importing them. On import, each file calls `registerProvider` for every capability it handles:

```
claude         → MCP, context-file (CLAUDE.md), skills, extension-modules,
                 slash-commands, hooks, custom-tools, settings, system-prompt
cursor         → MCP, rules (.mdc), settings
windsurf       → MCP, rules (.md + global_rules.md)
cline          → MCP, rules
codex          → MCP (openai-codex format)
gemini         → MCP (gemini CLI format)
vscode         → settings
opencode       → MCP
github         → context files (AGENTS.md)
agents-md      → context files (AGENTS.md in ancestors)
mcp-json       → MCP (.mcp.json)
ssh            → SSH host definitions
builtin        → built-in slash commands, bundled skills
claude-plugins → Claude plugin sources
agents         → agent definitions
```

Every provider carries a `priority` number (higher = wins ties). Claude Code's own config directories use priority 80; shared tool-agnostic providers such as Cursor and Windsurf use priority 50. This ordering ensures that an explicit `.claude/settings.json` takes precedence over, e.g., a `.cursor/settings.json` entry for the same key.

### Inheriting Rules from `.cursor` and `.windsurf`

On first run in a project directory, the discovery pass reads `.cursor/rules/*.mdc` (or `.cursorrules`) and `.windsurf/rules/*.md` (and `~/.codeium/windsurf/memories/global_rules.md`) into the `rule` capability. These rules appear alongside any `.claude` instructions without any explicit migration step.

```typescript
// packages/coding-agent/src/discovery/cursor.ts:35-38
const PROVIDER_ID = "cursor";
const DISPLAY_NAME = "Cursor";
const PRIORITY = 50;
// registers: MCP servers, rules, settings
```

```typescript
// packages/coding-agent/src/discovery/windsurf.ts:29-31
const PROVIDER_ID = "windsurf";
const DISPLAY_NAME = "Windsurf";
const PRIORITY = 50;
// registers: MCP servers, rules
```

MCP servers defined in those tools' config files are also transparently merged into the active server pool — a `.cursor/mcp.json` is surfaced the same way as `.claude/mcp.json`.

Sources: [packages/coding-agent/src/discovery/cursor.ts:1-220](), [packages/coding-agent/src/discovery/windsurf.ts:1-90]()

---

## Plugin System

### Plugin Manifest

A plugin is an npm package with an `omp` or `pi` field in `package.json`:

```typescript
// packages/coding-agent/src/extensibility/plugins/types.ts:27-48
export interface PluginManifest {
  name?: string;
  version: string;
  description?: string;
  tools?: string;          // entry point for custom tools
  hooks?: string;          // entry point for hooks
  extensions?: string[];   // extension module entry points
  commands?: string[];     // command .md files
  features?: Record<string, PluginFeature>;
  settings?: Record<string, PluginSettingSchema>;
}
```

Optional features allow selective installation — `pkg[feat1,feat2]` enables only named features; `pkg[*]` enables all; `pkg[]` enables none. Each feature can add its own `extensions`, `tools`, `hooks`, and `commands` arrays on top of the base manifest.

### Install, Link, Uninstall

`PluginManager` backs all plugin lifecycle operations. It manages a plugins sandbox directory with its own `package.json` and calls `bun install`/`bun uninstall` as child processes. Package names are validated against a strict regex and checked for shell metacharacters before being passed to the subprocess:

```typescript
// packages/coding-agent/src/extensibility/plugins/manager.ts:30-44
const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i;
function validatePackageName(name: string): void {
  // ...
  if (/[;&|`$(){}[\]<>\\]/.test(name)) {
    throw new Error(`Invalid characters in package name: ${name}`);
  }
}
```

Runtime state (enabled/disabled, selected features, settings) is persisted in a lockfile (`omp-plugins.lock.json`). Per-project overrides live in `.omp/plugin-overrides.json`, which can disable plugins or override feature sets without touching global state.

Sources: [packages/coding-agent/src/extensibility/plugins/manager.ts:51-232](), [packages/coding-agent/src/extensibility/plugins/types.ts:100-163]()

### Doctor Check

`PluginManager.doctor()` validates the full plugin tree: plugins directory, `package.json`, `node_modules` presence, per-plugin `omp`/`pi` manifest field, declared tool/hook/extension paths, and feature/config consistency. With `fix: true`, it can re-run `bun install` for missing modules and prune orphaned lockfile entries.

---

## Extension Modules

### What an Extension Can Do

An extension is a TypeScript (`.ts`) or JavaScript (`.js`) module that exports a factory function. The factory receives a `ConcreteExtensionAPI` instance and may call:

| Registration | Effect |
|---|---|
| `registerTool(def)` | Adds an LLM-callable tool to the active session |
| `registerCommand(name, opts)` | Adds a UI slash command with optional completion handler |
| `registerShortcut(key, opts)` | Binds a keyboard shortcut |
| `registerFlag(name, opts)` | Declares a named boolean/string flag |
| `registerMessageRenderer(type, fn)` | Renders custom message types in the TUI |
| `registerProvider(name, config)` | Registers a new AI provider at runtime |
| `on(event, handler)` | Subscribes to agent lifecycle events |

Sources: [packages/coding-agent/src/extensibility/extensions/loader.ts:119-258](), [packages/coding-agent/src/extensibility/extensions/types.ts:1-80]()

### Loading Order

`discoverAndLoadExtensions` assembles paths in priority order:

1. Native `.omp`/`.pi` extension modules discovered via the capability API (user `~/.claude/extensions/`, project `.claude/extensions/`)
2. Paths from all installed plugin manifests (`getAllPluginExtensionPaths`)
3. Explicitly configured paths from settings

Each path is deduplicated by resolved filesystem path before loading, and extensions named in `disabledExtensionIds` are filtered out. A shared `ExtensionRuntime` stub enforces that action methods (e.g., `sendMessage`, `setModel`) cannot be called during the factory phase — they throw `ExtensionRuntimeNotInitializedError` until the session fully boots.

```typescript
// packages/coding-agent/src/extensibility/extensions/loader.ts:511-550
// 1. Discover extension modules via capability API (native .omp/.pi only)
const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, { cwd });
// 2. Discover extension entry points from installed plugins
addPaths(await getAllPluginExtensionPaths(cwd));
// 3. Explicitly configured paths
for (const configuredPath of configuredPaths) { ... }
```

### Discovery Rules for Directories

When a configured path resolves to a directory, the loader checks for an `omp`/`pi` manifest in `package.json` first. If `manifest.extensions` is present, only those paths are loaded. Otherwise it falls back to `index.ts` → `index.js` → scanning one level of `*.ts`/`*.js` files. Recursion stops at one level; nested packages must declare entries in their manifest.

---

## Slash Commands and Skills

### Three-Layer Slash Command Loading

`loadSlashCommands()` uses the capability API to collect `SlashCommand` items from all providers, then appends any EMBEDDED_COMMAND_TEMPLATES that are not already present by name:

```typescript
// packages/coding-agent/src/extensibility/slash-commands.ts:162-200
export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}): Promise<FileSlashCommand[]> {
  const result = await loadCapability<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
  // ... map to FileSlashCommand with source label
  // Append embedded commands not already seen
  for (const cmd of EMBEDDED_SLASH_COMMANDS) { ... }
}
```

The `source` field on each command reveals its origin: `"via Claude Code User"`, `"via Cursor Project"`, `"bundled"`, etc. Commands from the `claude` provider are loaded from `.claude/commands/*.md` at user and project level, and can be toggled independently via `commands.enableClaudeUser` and `commands.enableClaudeProject` settings.

### Skill Discovery

Skills are Markdown files with a `SKILL.md` filename discovered in `~/.claude/skills/*/SKILL.md` (user-global) and `.claude/skills/*/SKILL.md` walking up from the project root to the repo root. This ancestor walk means skills committed at repository root are available to all subdirectory projects without any per-project configuration.

Sources: [packages/coding-agent/src/discovery/claude.ts:167-212]()

---

## Hook Injection

Hooks are shell scripts or TypeScript modules loaded from `~/.claude/hooks/{pre,post}/` and `.claude/hooks/{pre,post}/`. The loader discovers them through the `hook` capability, identifies the target tool from the filename (stripping `.sh`/`.bash`/`.zsh`/`.fish`), and wraps execution so the session can observe tool invocation boundaries.

TypeScript hook modules follow the same factory-function pattern as extensions: they export a `HookFactory` that receives a `HookAPI` and registers event handlers. Shell scripts are invoked via subprocess. Both variants can call `pi.sendMessage()` and `pi.appendEntry()` to emit custom messages into the session.

Sources: [packages/coding-agent/src/extensibility/hooks/loader.ts:1-60](), [packages/coding-agent/src/discovery/claude.ts:327-370]()

---

## MCP Wiring

### Configuration Sources

MCP server definitions are loaded through the `mcp` capability. The `claude` provider reads (in precedence order, stopping at the first populated file per scope):

- **User-level:** `~/.claude.json` → `~/.claude/mcp.json`
- **Project-level:** `.claude/.mcp.json` → `.claude/mcp.json`

The `cursor`, `windsurf`, `cline`, `codex`, `gemini`, and `opencode` providers each read their own tool-specific MCP config files, all normalizing to the same `MCPServer` shape with `command`, `args`, `env`, `url`, `headers`, `transport`, and `timeout` fields.

Environment variable expansion (`expandEnvVarsDeep`) is applied to the entire server config object before it is returned.

Sources: [packages/coding-agent/src/discovery/claude.ts:59-123]()

### Runtime Connection

`MCPManager` connects to discovered servers at session startup with a `STARTUP_TIMEOUT_MS = 250` grace period — servers that don't respond in 250 ms are not waited on synchronously; they connect in background and their tools become available as they join. The manager supports stdio, SSE, and HTTP transports. OAuth flows and Smithery registry lookups are supported for HTTP servers. MCP tools are surfaced as `LoadedCustomTool` objects, making them indistinguishable from native tools to the agent loop.

Sources: [packages/coding-agent/src/mcp/manager.ts:60-80](), [packages/coding-agent/src/mcp/loader.ts:1-60]()

---

## Architectural Overview

```text
┌─────────────────────────────────────────────────────────────┐
│                        Agent Session                         │
│                                                              │
│  ┌────────────────┐   ┌──────────────┐   ┌───────────────┐  │
│  │  Extensions    │   │    Hooks     │   │   MCP Tools   │  │
│  │  (TS modules)  │   │  (pre/post)  │   │  (any server) │  │
│  └───────┬────────┘   └──────┬───────┘   └──────┬────────┘  │
│          │                   │                   │           │
│          └──────────┬────────┘                   │           │
│                     ▼                            │           │
│          ┌──────────────────────────────────────┘           │
│          │         Capability Registry                        │
│          │  (slash-command / hook / skill / mcp / tool / …)  │
│          └────────────────────────┬─────────────────────────┘
│                                   │ loadCapability()          │
└───────────────────────────────────┼─────────────────────────┘
                                    │
         ┌──────────────────────────▼─────────────────────────┐
         │                  Discovery Providers                │
         │                                                     │
         │  claude (80) │ cursor (50) │ windsurf (50) │ …      │
         │  .claude/    │ .cursor/    │ .windsurf/    │        │
         └─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                     packages/ai                              │
│                                                             │
│  streamAnthropic  streamGoogle  streamOpenAI  streamBedrock  │
│         │               │            │             │         │
│         └───────────────┴────────────┴─────────────┘        │
│                 createLazyStream() → LazyProviderModule      │
└─────────────────────────────────────────────────────────────┘
```

---

## Safe-Change Rules

### Adding a New AI Provider

1. Create `packages/ai/src/providers/<name>.ts` exporting a `stream<Name>` function that returns `AssistantMessageEventStream`.
2. Add a `LazyProviderModule` interface and a module-level `Promise` cache variable in `register-builtins.ts`.
3. Implement a `load<Name>ProviderModule()` function using the `||=` cache pattern.
4. Export a `stream<Name>` constant from `createLazyStream(load<Name>ProviderModule)`.
5. **Do not** eagerly import SDK packages at module top-level — the lazy-load invariant keeps startup time bounded regardless of how many providers exist.
6. **Do not** call `target.end()` more than once — `forwardStream` already handles the normal and error paths.

### Adding a New Discovery Provider

1. Create `packages/coding-agent/src/discovery/<tool>.ts`.
2. Call `registerProvider(capabilityId, { id, displayName, priority, load })` for each capability your tool contributes. Use priority ≤ 80 (claude's priority) to avoid overriding explicit user config.
3. Import the new module in `packages/coding-agent/src/discovery/index.ts` — the import itself triggers registration.
4. **Do not** modify `loadCapability` or the registry internals — providers register themselves by side-effect, and the registry merges results without manual wiring.

### Adding a New Plugin Feature

1. Extend `PluginFeature` in `types.ts` only if a new category of entry points is needed.
2. `PluginManager.doctor()` must be updated to validate any new manifest paths.
3. Project overrides in `.omp/plugin-overrides.json` intentionally shadow global state; new fields must respect that override layer.

### Adding a New Extension API Method

1. Declare the method in `IExtensionRuntime` (the interface) in `types.ts`.
2. Add a throwing stub in `ExtensionRuntime` (the stub class) — this enforces that action methods cannot fire during the factory phase.
3. Implement in `ConcreteExtensionAPI`, delegating to `this.runtime`.
4. **Do not** store session state inside the `Extension` object (handlers, tools, commands, flags) — those maps are owned by the factory phase only; runtime state belongs in `ExtensionRuntime`.

The boundary between registration-time (factory call) and runtime (session active) is the core invariant of the extension system: breaking it would allow extensions to emit messages or mutate the tool set before the session is ready to accept them.

Sources: [packages/coding-agent/src/extensibility/extensions/loader.ts:46-112](), [packages/coding-agent/src/extensibility/plugins/types.ts:9-22]()
