# Extensions

> TypeScript extension registration, inline naming, active tools on next turn, OAuth prompt input, and shutdown cleanup contracts.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human docs: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1
- Complete Markdown: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1/llms-full.txt

## Source Files

- `packages/coding-agent/examples/extensions/README.md`
- `packages/coding-agent/examples/sdk/06-extensions.ts`
- `packages/coding-agent/examples/extensions/auto-commit-on-exit.ts`
- `packages/coding-agent/test/suite/regressions/6260-inline-extension-naming.test.ts`
- `packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts`
- `packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts`

---

---
title: "Extensions"
description: "TypeScript extension registration, inline naming, active tools on next turn, OAuth prompt input, and shutdown cleanup contracts."
---

Extensions are TypeScript modules that receive an `ExtensionAPI` instance and can register tools, commands, event handlers, and UI hooks. Discovery uses standard agent directories and optional loader paths; SDK embeds can also pass inline factories through `DefaultResourceLoader`.

## Extension shape

An extension is a TypeScript file whose **default export** is a factory:

```ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  // register tools, commands, and event handlers
}
```

The factory may be:

| Form | How it is supplied | Display path after load |
|------|--------------------|-------------------------|
| File path | Discovery, `--extension`, `additionalExtensionPaths`, or settings | Real filesystem path |
| Bare factory | `extensionFactories: [(pi) => { ... }]` | `<inline:N>` (1-based) |
| Named wrapper | `extensionFactories: [{ name, factory, hidden? }]` | `<inline:name>` |

## Discovery and load

By default, extension files are discovered from:

- `~/.pi/agent/extensions/`
- `<cwd>/.pi/extensions/`
- Paths listed in settings.json under the `"extensions"` array

CLI one-off load:

```bash
pi --extension examples/extensions/permission-gate.ts
```

Or install for auto-discovery:

```bash
cp permission-gate.ts ~/.pi/agent/extensions/
```

### SDK / `DefaultResourceLoader`

Embedders construct a loader, optionally add paths and factories, then `reload()` before `createAgentSession`:

```ts
import {
  createAgentSession,
  DefaultResourceLoader,
  getAgentDir,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const resourceLoader = new DefaultResourceLoader({
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  additionalExtensionPaths: [
    "./my-logging-extension.ts",
    "./my-safety-extension.ts",
  ],
  extensionFactories: [
    (pi) => {
      pi.on("agent_start", () => {
        console.log("[Inline Extension] Agent starting");
      });
    },
  ],
});
await resourceLoader.reload();

const { session } = await createAgentSession({
  resourceLoader,
  sessionManager: SessionManager.inMemory(),
});
```

After load, `loader.getExtensions()` returns the registered extension list (used for identity, hidden state, and diagnostics).

## Inline extension naming

When factories are passed via `extensionFactories` (not file paths), `DefaultResourceLoader` assigns synthetic paths:

| Input | Resulting `path` | Notes |
|-------|------------------|--------|
| Bare `(pi) => {}` | `<inline:1>`, `<inline:2>`, … | Index is 1-based over bare factories in load order |
| `{ name: "my-provider", factory }` | `<inline:my-provider>` | Name is used as the path suffix |
| `{ name: "built-in", factory, hidden: true }` | `<inline:built-in>` | `hidden: true` is preserved on the extension record |
| Mixed list: bare, named, bare | `<inline:1>`, `<inline:named-ext>`, `<inline:3>` | Named entries do not consume bare indices; bare numbering continues |

```ts
// Bare factories
extensionFactories: [noop, noop]
// → paths: <inline:1>, <inline:2>

// Named wrappers
extensionFactories: [
  { name: "my-provider", factory: noop },
  { name: "my-commands", factory: noop },
]
// → paths: <inline:my-provider>, <inline:my-commands>
```

## Registration surface

Inside the factory, typical `ExtensionAPI` operations include:

| API | Role |
|-----|------|
| `pi.on(event, handler)` | Subscribe to lifecycle / tool events |
| `pi.registerTool({ ... })` | Register a custom tool (name, label, description, parameters, execute) |
| `pi.registerCommand(name, { description, handler })` | Register a slash command |
| `pi.setActiveTools(names)` | Replace the active tool name set |
| `pi.getActiveTools()` | Read the current active tool names |
| `pi.exec(cmd, args)` | Run a subprocess (e.g. git) |
| `pi.events` | Inter-extension event bus (see example extensions) |

### Example: tools, commands, and tool blocking

```ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("agent_start", async () => {
    console.log("[Extension] Agent starting");
  });

  pi.on("tool_call", async (event) => {
    console.log(`[Extension] Tool: ${event.toolName}`);
    // Return { block: true, reason: "..." } to block execution
    return undefined;
  });

  pi.on("agent_end", async (event) => {
    console.log(`[Extension] Done, ${event.messages.length} messages`);
  });

  pi.registerTool({
    name: "my_tool",
    label: "My Tool",
    description: "Does something useful",
    parameters: Type.Object({
      input: Type.String(),
    }),
    execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => ({
      content: [{ type: "text", text: `Processed: ${params.input}` }],
      details: {},
    }),
  });

  pi.registerCommand("mycommand", {
    description: "Do something",
    handler: async (args, ctx) => {
      ctx.ui.notify(`Command executed with: ${args}`);
    },
  });
}
```

### Common events (from examples and tests)

| Event | Typical use |
|-------|-------------|
| `agent_start` / `agent_end` | Logging, session bookends |
| `tool_call` | Gate or log tool execution; optional block |
| `before_agent_start` | Override `systemPrompt` for the run |
| `session_start` | Late tool registration after startup |
| `session_shutdown` | Cleanup (git commit, sockets, resources) |
| `project_trust` | Trust prompts for user/global and CLI extensions |
| `model_select` | React to model changes (status bar, etc.) |
| `input` | Transform user input (e.g. expand `!{command}`) |
| `user_bash` | Interactive shell passthrough |
| `resources_discover` | Load skills, prompts, and themes dynamically |

## Active tools on the next provider turn

`pi.setActiveTools(names)` changes which tools the provider sees **on the next request in the same run**, not only on a later user turn.

```mermaid
sequenceDiagram
  participant Provider
  participant Session
  participant ExtensionTool

  Provider->>Session: tools = [switch_tools]
  Session->>ExtensionTool: execute switch_tools
  ExtensionTool->>Session: pi.setActiveTools(["after_switch"])
  ExtensionTool-->>Session: tool result
  Session->>Provider: tools = [after_switch]
  Provider-->>Session: final assistant message
```

### Contracts (regression-backed)

| Behavior | Contract |
|----------|----------|
| Mid-run refresh | After a tool calls `pi.setActiveTools(["after_switch"])`, the **next** provider context lists `after_switch`, not the previous set |
| Session API | `session.getActiveToolNames()` matches the post-switch set after the prompt completes |
| Additive load | `pi.setActiveTools([...pi.getActiveTools(), "after_load"])` keeps prior tools and adds the new one |
| Tool-result audit | Additive names appear on the current tool-result message as `addedToolNames` (e.g. `["after_load"]`) |
| System prompt stability | A `before_agent_start` handler that returns `{ systemPrompt: ... }` keeps that override on **both** provider requests when tools change mid-run |

Minimal switch pattern:

```ts
pi.registerTool({
  name: "switch_tools",
  // ...
  execute: async () => {
    pi.setActiveTools(["after_switch"]);
    return {
      content: [{ type: "text", text: "switched" }],
      details: {},
    };
  },
});
```

Additive pattern (records delta on the tool result):

```ts
execute: async () => {
  pi.setActiveTools([...pi.getActiveTools(), "after_load"]);
  return {
    content: [{ type: "text", text: "loaded" }],
    details: {},
  };
};
```

## Shutdown cleanup contracts

Extensions that must release resources on exit should handle `session_shutdown`. `runtimeHost.dispose` is what emits that event for the interactive runtime.

### `session_shutdown` handler example

```ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("session_shutdown", async (_event, ctx) => {
    const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
    if (code !== 0 || status.trim().length === 0) {
      return;
    }
    // ... build commit message from ctx.sessionManager.getEntries()
    await pi.exec("git", ["add", "-A"]);
    const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
    if (commitCode === 0 && ctx.hasUI) {
      ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
    }
  });
}
```

### Ordering: signal vs interactive quit

Graceful shutdown order depends on the trigger. Extension teardown that does **not** write to the TTY (sockets, files, git) must not be skipped when a later terminal restore fails.

| Trigger | Order | Rationale |
|---------|-------|-----------|
| Signal (`SIGTERM` / `SIGHUP`, `fromSignal: true`) | `runtimeHost.dispose` → terminal `drainInput` → `stop` | Emit `session_shutdown` **before** terminal writes so extension cleanup runs even if the TTY is dead or stalled |
| Interactive quit (Ctrl+D, `/quit`) | `drainInput` → `stop` → `dispose` | Preserve the final TUI frame before extension dispose |
| Re-entrant (`isShuttingDown` already true) | No-op | `dispose` is not called again |

```text
Signal path:     dispose  →  drainInput  →  stop
Interactive:     drainInput  →  stop  →  dispose
Re-entrant:      (no-op)
```

### Resume hint (interactive only)

On interactive quit with a **persisted** session file and a TTY stdout, the process may print:

```text
To resume this session: pi --session <session-id>
```

Signal-triggered shutdown does **not** print that resume hint.

### Extension guidance for shutdown

- Put non-TTY cleanup on `session_shutdown` so signal teardown still runs it.
- Prefer work that does not depend on a live terminal (filesystem, subprocesses, sockets).
- For an explicit quit command surface, see the `shutdown-command` example (`ctx.shutdown()` / `/quit`).
- Treat re-entrancy as safe: handlers should tolerate a single dispose path and no double-fire from re-entry.

## Example catalog (reference)

Under `packages/coding-agent/examples/extensions/`, sample extensions cover lifecycle gates, custom tools, commands/UI, git hooks, system prompt and compaction, resources, and messaging. Representative entries:

| Area | Examples |
|------|----------|
| Lifecycle & safety | `permission-gate.ts`, `project-trust.ts`, `protected-paths.ts`, `confirm-destructive.ts`, `dirty-repo-guard.ts`, `sandbox/`, `gondolin/` |
| Custom tools | `todo.ts`, `hello.ts`, `dynamic-tools.ts`, `tool-override.ts`, `structured-output.ts`, `subagent/` |
| Commands & UI | `preset.ts`, `plan-mode/`, `tools.ts`, `status-line.ts`, `doom-overlay/`, `shutdown-command.ts`, `reload-runtime.ts` |
| Git | `git-checkpoint.ts`, `auto-commit-on-exit.ts` |
| Prompt / compaction | `pirate.ts`, `claude-rules.ts`, `custom-compaction.ts`, `trigger-compact.ts` |
| Resources / messages | `dynamic-resources/`, `message-renderer.ts`, `entry-renderer.ts`, `event-bus.ts` |

Load any of them with `pi --extension <path>` for local experiments.

## Constraints and failure modes

| Topic | Behavior |
|-------|----------|
| Tool gate | `tool_call` may return `{ block: true, reason }` to stop execution |
| Active tools | Changes apply on the **next** provider request within the same agent run |
| Mid-run prompt | `before_agent_start` system prompt overrides must remain effective after tool-set changes |
| Signal cleanup | Dispose (and thus `session_shutdown`) runs before terminal drain on signal paths |
| Interactive quit | TUI stop precedes dispose; resume hint may appear for persisted sessions |
| Re-entrant shutdown | Second shutdown is a no-op; dispose is not re-invoked |
| Inline identity | Bare factories are numbered; named wrappers use `<inline:name>`; `hidden` is retained |

## Related pages

<CardGroup>
  <Card title="Tools and allowlists" href="/tools">
    Default tools, extension tools, allowlists, and blocked-tool termination.
  </Card>
  <Card title="SDK" href="/sdk">
    Embed pi: resource loaders, custom tools, settings, and session construction.
  </Card>
  <Card title="SDK examples" href="/sdk-examples">
    Copy-paste recipes including extensions configuration.
  </Card>
  <Card title="Extension examples" href="/extension-examples">
    Reference packages: subagent, plan-mode, doom-overlay, dynamic resources, auto-commit-on-exit.
  </Card>
  <Card title="Session runtime" href="/session-runtime">
    Runtime services, lifecycle events, and dispose without the interactive TUI.
  </Card>
  <Card title="Themes and packages" href="/themes-and-packages">
    Shareable Pi packages that bundle extensions, skills, templates, and themes.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    SIGTERM cleanup ordering, credential issues, and related operational failures.
  </Card>
</CardGroup>
