Agent-readable wiki

oh-my-pi (omp) Mental Model Wiki

omp is a batteries-included coding agent CLI and SDK — a TypeScript/Rust monorepo that wires a multi-provider LLM loop, 32+ built-in tools, LSP, DAP, and an embedded shell into a single terminal process. Study it to understand how a production-grade agent harness manages state, tool execution, and extensibility.

Pages

  1. The Mental Model — One Picture of the Whole SystemThe simplest useful model of omp: what it is, how its layers relate, what the invariants are, and which facts let you predict behavior without reopening the code.
  2. Agent Loop — The Turn Cycle & Harmony Safety LayerHow the agent loop drives LLM turns, executes tool calls, coerces malformed results, and detects GPT-5 Harmony protocol leakage — the core invariant that keeps session files valid.
  3. Native Layer — Rust Crates & N-API AddonThe ~27k-line Rust underbelly: pi-natives (N-API cdylib), pi-shell (embedded bash/PTY), pi-ast (tree-sitter summarizer), pi-iso (workspace isolation), and how each module eliminates fork/exec on the hot path.
  4. Four Run Modes — Interactive, Print, RPC, and ACPSame engine, four wrappers: the TUI interactive mode, one-shot print mode, NDJSON RPC over stdio, and ACP/JSON-RPC for editors. Understand which mode owns the I/O boundary, what framing it uses, and when tool calls route through the host.
  5. Tools, LSP & DAP — What the Agent Can TouchThe 32 built-in tools (read, write, bash, search, eval, fetch, gh…), the 13 LSP operations wired through workspace/willRenameFiles, and the 27 DAP operations that attach real debuggers. State ownership, caching boundaries, and what differs between tool categories.
  6. Extension Model — Providers, Plugins, Skills & Safe-Change RulesHow 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.

Complete Markdown

# oh-my-pi (omp) Mental Model Wiki

> omp is a batteries-included coding agent CLI and SDK — a TypeScript/Rust monorepo that wires a multi-provider LLM loop, 32+ built-in tools, LSP, DAP, and an embedded shell into a single terminal process. Study it to understand how a production-grade agent harness manages state, tool execution, and extensibility.

## Context Links

- [Agent index](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45)
- [GitHub repository](https://github.com/can1357/oh-my-pi)

## Repository Metadata

- Repository: can1357/oh-my-pi

- Generated: 2026-05-21T22:04:00.959Z
- Updated: 2026-05-21T22:21:04.433Z
- Runtime: Claude Code
- Format: Mental Model
- Pages: 6

## Page Index

- 01. [The Mental Model — One Picture of the Whole System](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/01-the-mental-model-one-picture-of-the-whole-system.md) - The simplest useful model of omp: what it is, how its layers relate, what the invariants are, and which facts let you predict behavior without reopening the code.
- 02. [Agent Loop — The Turn Cycle & Harmony Safety Layer](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/02-agent-loop-the-turn-cycle-harmony-safety-layer.md) - How the agent loop drives LLM turns, executes tool calls, coerces malformed results, and detects GPT-5 Harmony protocol leakage — the core invariant that keeps session files valid.
- 03. [Native Layer — Rust Crates & N-API Addon](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/03-native-layer-rust-crates-n-api-addon.md) - The ~27k-line Rust underbelly: pi-natives (N-API cdylib), pi-shell (embedded bash/PTY), pi-ast (tree-sitter summarizer), pi-iso (workspace isolation), and how each module eliminates fork/exec on the hot path.
- 04. [Four Run Modes — Interactive, Print, RPC, and ACP](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/04-four-run-modes-interactive-print-rpc-and-acp.md) - Same engine, four wrappers: the TUI interactive mode, one-shot print mode, NDJSON RPC over stdio, and ACP/JSON-RPC for editors. Understand which mode owns the I/O boundary, what framing it uses, and when tool calls route through the host.
- 05. [Tools, LSP & DAP — What the Agent Can Touch](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/05-tools-lsp-dap-what-the-agent-can-touch.md) - The 32 built-in tools (read, write, bash, search, eval, fetch, gh…), the 13 LSP operations wired through workspace/willRenameFiles, and the 27 DAP operations that attach real debuggers. State ownership, caching boundaries, and what differs between tool categories.
- 06. [Extension Model — Providers, Plugins, Skills & Safe-Change Rules](https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/06-extension-model-providers-plugins-skills-safe-change-rules.md) - 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.

## Source File Index

- `Cargo.toml`
- `crates/pi-ast/Cargo.toml`
- `crates/pi-iso/Cargo.toml`
- `crates/pi-natives/build.rs`
- `crates/pi-natives/Cargo.toml`
- `crates/pi-shell/Cargo.toml`
- `packages/agent/src/agent-loop.ts`
- `packages/agent/src/agent.ts`
- `packages/agent/src/compaction.ts`
- `packages/agent/src/harmony-leak.ts`
- `packages/agent/src/run-collector.ts`
- `packages/agent/src/telemetry.ts`
- `packages/agent/src/thinking.ts`
- `packages/agent/src/types.ts`
- `packages/ai/src/models.json`
- `packages/ai/src/providers`
- `packages/coding-agent/src/dap`
- `packages/coding-agent/src/discovery`
- `packages/coding-agent/src/extensibility/extensions`
- `packages/coding-agent/src/extensibility/plugins`
- `packages/coding-agent/src/extensibility/slash-commands.ts`
- `packages/coding-agent/src/lsp/client.ts`
- `packages/coding-agent/src/lsp/index.ts`
- `packages/coding-agent/src/main.ts`
- `packages/coding-agent/src/mcp`
- `packages/coding-agent/src/modes/acp`
- `packages/coding-agent/src/modes/index.ts`
- `packages/coding-agent/src/modes/interactive-mode.ts`
- `packages/coding-agent/src/modes/print-mode.ts`
- `packages/coding-agent/src/modes/rpc`
- `packages/coding-agent/src/sdk.ts`
- `packages/coding-agent/src/tools/bash.ts`
- `packages/coding-agent/src/tools/eval.ts`
- `packages/coding-agent/src/tools/index.ts`
- `packages/coding-agent/src/tools/read.ts`
- `packages/natives`
- `README.md`

---

## 01. The Mental Model — One Picture of the Whole System

> The simplest useful model of omp: what it is, how its layers relate, what the invariants are, and which facts let you predict behavior without reopening the code.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/01-the-mental-model-one-picture-of-the-whole-system.md
- Generated: 2026-05-21T21:58:26.271Z

### Source Files

- `README.md`
- `Cargo.toml`
- `packages/coding-agent/src/main.ts`
- `packages/coding-agent/src/sdk.ts`
- `packages/agent/src/agent.ts`
- `packages/agent/src/types.ts`

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

- [README.md](README.md)
- [Cargo.toml](Cargo.toml)
- [AGENTS.md](AGENTS.md)
- [packages/coding-agent/src/main.ts](packages/coding-agent/src/main.ts)
- [packages/coding-agent/src/sdk.ts](packages/coding-agent/src/sdk.ts)
- [packages/agent/src/agent.ts](packages/agent/src/agent.ts)
- [packages/agent/src/agent-loop.ts](packages/agent/src/agent-loop.ts)
- [packages/agent/src/types.ts](packages/agent/src/types.ts)
- [packages/coding-agent/src/session/agent-session.ts](packages/coding-agent/src/session/agent-session.ts)
- [packages/coding-agent/src/modes/index.ts](packages/coding-agent/src/modes/index.ts)
</details>

# The Mental Model — One Picture of the Whole System

omp (oh-my-pi) is a full-stack AI coding agent — a CLI tool with the IDE wired in. It wraps LLM calls in a stateful, multi-turn agent loop, exposes a tool harness for filesystem, shell, LSP, MCP, and browser operations, and renders output through a terminal UI. Understanding how its layers relate — and what invariants hold across them — lets you predict behavior and debug integration problems without re-reading the code each time.

This page presents the smallest useful model of omp: what the layers are, which direction dependencies flow, what each layer owns, and which facts let you predict how the whole system behaves.

---

## The Three-Layer Stack

omp splits cleanly into three layers. Each layer depends only downward; upper layers are never imported by lower layers.

```text
┌──────────────────────────────────────────────────────┐
│              CLI / Run Modes                         │
│   main.ts · InteractiveMode · RpcMode · AcpMode      │
│   (I/O, arg parsing, session lifecycle, TUI)         │
├──────────────────────────────────────────────────────┤
│              Session / SDK                           │
│   createAgentSession() · AgentSession                │
│   (tools, skills, extensions, MCP, LSP, memory)     │
├──────────────────────────────────────────────────────┤
│              Agent Core                              │
│   Agent class · agentLoop() · streamSimple()         │
│   (LLM call, streaming, tool dispatch, events)      │
├──────────────────────────────────────────────────────┤
│   pi-ai (multi-provider LLM client)                  │
│   pi-natives (Rust: grep, AST, PTY, shell)           │
└──────────────────────────────────────────────────────┘
```

Sources: [AGENTS.md]() (package table), [packages/coding-agent/src/sdk.ts:156-284](), [packages/agent/src/agent.ts:242-310]()

---

## Layer 1 — Agent Core (`packages/agent`)

The agent core is a provider-neutral, runtime-agnostic conversation engine. It knows nothing about coding tools, sessions, or file systems.

### `Agent` class

`Agent` is the observable state machine around the agent loop. It owns:

- `AgentState` — `model`, `systemPrompt`, `tools`, `messages`, `isStreaming`, `pendingToolCalls`
- steering queue and follow-up queue
- an `AbortController` that cancels in-flight LLM calls

Key methods:

| Method | What it does |
|--------|-------------|
| `prompt(input)` | Start a new turn; throws `AgentBusyError` if already streaming |
| `continue()` | Resume from existing context (steering queues, retries) |
| `steer(msg)` | Enqueue a mid-run interrupt; delivered after the current tool completes |
| `followUp(msg)` | Enqueue a message delivered only when the agent would otherwise stop |
| `abort()` | Fire the abort controller; in-flight tool calls see a cancelled signal |
| `subscribe(fn)` | Receive `AgentEvent` stream (streaming, tool calls, completions) |

Sources: [packages/agent/src/agent.ts:242-260](), [packages/agent/src/agent.ts:650-665]()

### `agentLoop` — the turn engine

`agentLoop` is an async generator. One call covers one complete reasoning turn:

1. Optionally drain steering messages (if `interruptMode: "immediate"`, checked after every tool call).
2. Call `convertToLlm()` to filter `AgentMessage[]` down to wire-format `Message[]`.
3. Optionally call `transformContext()` for context pruning / injection.
4. Call `syncContextBeforeModelCall()` so live tool / prompt changes are captured.
5. Stream from the LLM via `streamFn` (defaults to `streamSimple` from `pi-ai`).
6. Execute tool calls — concurrent unless `concurrency: "exclusive"`.
7. Run `beforeToolCall` and `afterToolCall` hooks.
8. Call `getSteeringMessages()` after each tool; if any arrive, skip remaining tools and restart.
9. Call `getFollowUpMessages()` when stopping; if any arrive, continue.

Sources: [packages/agent/src/agent-loop.ts:1-64](), [packages/agent/src/types.ts:30-216]()

### `AgentMessage` vs `Message`

`AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]`

The distinction is structural. LLM providers only see `Message` (user/assistant/toolResult). `AgentMessage` can include UI-only custom types (notifications, artifacts). The `convertToLlm` function at the LLM boundary filters them out. This is what allows session storage and UI to share the same message array without leaking app-level concerns to the API payload.

Sources: [packages/agent/src/types.ts:305-315]()

### Event model

`Agent` emits a typed `AgentEvent` stream:

```
agent_start
  └─ turn_start
       ├─ message_start  (assistant streaming begins)
       │    └─ message_update  (token deltas)
       ├─ message_end    (full assistant message committed)
       ├─ tool_execution_start
       │    └─ tool_execution_update  (streaming partial results)
       ├─ tool_execution_end
       └─ turn_end
agent_end  (carries telemetry summary if OTEL enabled)
```

Subscribers get fine-grained lifecycle hooks for every message and tool call. The TUI layer, session storage, and RPC mode all drive from these events.

Sources: [packages/agent/src/types.ts:429-451]()

---

## Layer 2 — Session / SDK (`packages/coding-agent/src/sdk.ts` + `session/`)

The SDK layer wires everything that makes omp a *coding* agent: tools, extensions, skills, MCP, LSP, memory, and session persistence. The entry point is `createAgentSession(options)`.

### `createAgentSession` startup sequence

```text
createAgentSession(options)
  │
  ├─ [parallel] discoverContextFiles()   → AGENTS.md walking
  ├─ [parallel] discoverPromptTemplates()
  ├─ [parallel] discoverSlashCommands()
  ├─ [parallel] discoverSkills()
  ├─ [parallel] buildWorkspaceTree()     → native Rust scan
  ├─ [parallel] modelRegistry.refreshInBackground()
  │
  ├─ Settings.init()                     → layered config
  ├─ loadSecrets() + SecretObfuscator    → env + file secrets
  ├─ SessionManager                      → JSONL session file
  ├─ createTools()                       → built-in tool set
  ├─ discoverAndLoadExtensions()         → .omp/extensions/
  ├─ discoverAndLoadMCPTools()           → .mcp.json
  ├─ discoverStartupLspServers()         → LSP warmup
  │
  └─ new AgentSession(config)
       └─ new Agent(opts)                → agent core
```

Parallel discovery fans out early because the workspace tree scan, context file walk, and skill loading are I/O-bound and independent. They converge at their respective consumer sites inside `AgentSession`.

Sources: [packages/coding-agent/src/sdk.ts:714-834]()

### `AgentSession`

`AgentSession` is the durable, multi-turn session facade used by all run modes. It:

- Wraps the `Agent` core and surfaces `prompt()`, `promptCustomMessage()`, `dispose()`
- Manages `SessionManager` (JSONL append-log for message persistence)
- Owns the tool set, extension runner, skills, memory backend, and async job manager
- Handles model cycling (`Ctrl+P`), thinking level changes, plan mode, and compaction
- Routes IRC/subagent dispatch via `AgentRegistry`

The `AgentSession` is **shared** across all run modes — interactive, print, RPC, and ACP all call the same `session.prompt()`. Mode code adds I/O on top; it does not own the session.

Sources: [packages/coding-agent/src/session/agent-session.ts:4-270]()

### Tool architecture

Tools live in `packages/coding-agent/src/tools/`. They implement `AgentTool<TParameters, TDetails>` from the agent core. Key invariants:

| Property | Meaning |
|---------|---------|
| `loadMode: "essential"` | Loaded at startup unconditionally |
| `loadMode: "discoverable"` | Activated on demand by tool-search index |
| `concurrency: "exclusive"` | Runs alone — no other tools execute in parallel |
| `hidden: true` | Not shown unless explicitly listed via `--tools` |
| `deferrable: true` | Can stage a pending action requiring `resolve` tool |
| `nonAbortable: true` | Ignores abort signals; runs to completion |

Built-in tools include `bash`, `read`, `write`, `edit`, `find`, `search`, `fetch`, `eval` (Python kernel), and more. Extensions, MCP servers, and custom tools register additional tools into the same registry.

Sources: [packages/coding-agent/src/sdk.ts:121-146](), [packages/agent/src/types.ts:373-415]()

### Extension, skill, and MCP layers

All three are optional, composable extensibility layers:

- **Extensions** — TypeScript modules loaded from `.omp/extensions/` (or explicit paths). They receive `ExtensionContext` and can register tools, custom commands, and UI actions.
- **Skills** — Markdown files (`SKILL.md`) injected into the system prompt at the `skills.includeSkills` list. They carry no runtime code.
- **MCP (Model Context Protocol)** — External servers declared in `.mcp.json`. Their tools are discovered and registered as `AgentTool` adapters. In ACP mode, the host supplies MCP servers; the session disables `.mcp.json` discovery (`enableMCP: false`) to avoid shadowing.

Sources: [packages/coding-agent/src/sdk.ts:59-90](), [packages/coding-agent/src/main.ts:219-245]()

---

## Layer 3 — CLI / Run Modes (`packages/coding-agent/src/main.ts` + `modes/`)

The top layer parses arguments, initializes settings, creates the session, and dispatches to one of four run modes:

| Mode | Entry | When used |
|------|-------|-----------|
| Interactive | `InteractiveMode` (TUI) | Default terminal session |
| Print | `runPrintMode` | `--print`, piped input, non-TTY |
| RPC | `runRpcMode` | `--mode rpc` / `--mode rpc-ui` — IDE extensions |
| ACP | `runAcpMode` | `--mode acp` — Agent Communication Protocol |

All modes call `session.prompt()` or `session.promptCustomMessage()`. They differ only in how they acquire input and render output.

The main entry point `runRootCommand` performs a fixed startup sequence:

1. `initTheme` (early, for symbols)
2. `maybeAutoChdir` (redirect away from `$HOME`)
3. `discoverAuthStorage` + `ModelRegistry`
4. `Settings.init` + model role overrides
5. Plugin/marketplace preload (background)
6. `createSessionManager` (continue / resume / fork / new)
7. `buildSessionOptions` (model, system prompt, tools, skills, extensions)
8. `createAgentSession` → `AgentSession`
9. Dispatch to mode

Sources: [packages/coding-agent/src/main.ts:692-1028]()

### ACP mode and session isolation

In ACP mode, each `session/new` RPC call creates a fresh `AgentSession` via the factory returned by `createAcpSessionFactory`. The factory forces `enableMCP: false` on every session: MCP servers come from the ACP client's `session/new.mcpServers`, not from `.mcp.json` on disk. Without this, host-disk tools shadow client-supplied servers (issue #1234).

Sources: [packages/coding-agent/src/main.ts:202-244]()

---

## Dependency Direction

```text
  coding-agent (CLI)
      │  imports
      ▼
  packages/agent  (Agent, agentLoop, types)
      │  imports
      ▼
  packages/ai  (streamSimple, providers, LLM types)
      │  calls
      ▼
  LLM Provider APIs  (Anthropic, OpenAI, Gemini, …)

  packages/coding-agent
      │  also imports
      ▼
  crates/pi-natives  (Rust NAPI bindings via packages/natives)
  packages/tui       (terminal rendering)
  packages/utils     (logger, Snowflake, env, path utils)
```

`packages/agent` has no knowledge of coding-agent session types. `packages/ai` has no knowledge of tools or sessions. This means the agent core and LLM client are reusable outside the coding-agent context.

Sources: [AGENTS.md]() (package table), [packages/agent/src/agent.ts:1-36]()

---

## System Prompt Architecture

The system prompt is not a single string. It is an array of blocks (`string[]`) built at session creation from multiple sources:

1. **Harness prompt** — stable built-in instructions (from `.md` files in `prompts/system/`)
2. **Workspace tree** — rendered directory listing from the native scan
3. **Context files** — content of every `AGENTS.md` found walking up from `cwd`
4. **Skills** — `SKILL.md` content for each skill in `includeSkills`
5. **Rules** — content of `RULES.md` and `.omp/rules/` files
6. **Custom overrides** — `SYSTEM.md` discovered from `.omp/SYSTEM.md` or `~/.omp/`; `APPEND_SYSTEM.md` appended at the end

The CLI `--system-prompt` flag replaces block 0; `--append-system-prompt` appends after all defaults. The `systemPrompt` option in `CreateAgentSessionOptions` accepts either a `string[]` (full replacement) or a function `(default) => final` (surgical modification).

Prompts are never built inline in TypeScript. They live in static `.md` files imported with `{ type: "text" }`, with Handlebars for dynamic content.

Sources: [packages/coding-agent/src/sdk.ts:535-645](), [packages/coding-agent/src/main.ts:495-521](), [AGENTS.md]() (prompts rule)

---

## Session Persistence

Sessions are stored as JSONL append-logs managed by `SessionManager`. Each entry is a serialized `AgentMessage`. On resume (`--continue`, `--resume`), the log is replayed into the `Agent`'s message array before the first prompt.

`SessionManager` supports four modes: `create` (new), `open` (exact path or ID), `continueRecent` (latest for `cwd`), and `forkFrom` (copy-on-write branch of an existing session). The in-memory variant (`SessionManager.inMemory()`) is used with `--no-session`.

The session ID doubles as the `providerSessionId` forwarded to LLM providers that support session-based caching (e.g., OpenAI Codex), keeping provider-side cache isolation aligned with the on-disk session file.

Sources: [packages/coding-agent/src/main.ts:377-443](), [packages/coding-agent/src/sdk.ts:805-810]()

---

## Key Invariants

These facts predict behavior without reopening the code:

1. **`Agent` is synchronous in state, async in execution.** `#state` is mutated directly; listeners are notified synchronously after each mutation. There is no reducer pattern.

2. **One active prompt at a time.** `prompt()` throws `AgentBusyError` if `isStreaming`. Steering messages are queued, not injected mid-call.

3. **Tools transform at the LLM boundary only.** `convertToLlm` is the single choke point. Custom `AgentMessage` types survive in memory and session storage, but never reach the API.

4. **All discovery is parallel at startup; nothing re-scans during a session.** Context files, workspace tree, skills, and prompt templates are computed once in `createAgentSession` and passed to the session constructor. A session restart is needed to pick up new `AGENTS.md` files.

5. **Model identity is always provider/id, never a bare name.** `Model.provider` + `Model.id` is the stable key. Role aliases (`default`, `smol`, `slow`, `plan`) are resolved at startup via `resolveModelRoleValue`; changes after session creation require `session.setModel()`.

6. **Extensions, MCP, and custom tools share the same `AgentTool` registry.** The agent loop cannot distinguish between a built-in tool, an extension tool, and an MCP adapter. This means all tool features (`concurrency`, `beforeToolCall`, etc.) apply uniformly.

7. **`AgentSession` is run-mode-agnostic.** All four run modes (interactive, print, RPC, ACP) call the same `session.prompt()`. Mode code is a thin I/O wrapper, not a reimplementation of session logic.

Sources: [packages/agent/src/agent.ts:758-760](), [packages/agent/src/types.ts:305-315](), [packages/coding-agent/src/sdk.ts:763-787](), [packages/coding-agent/src/session/agent-session.ts:4-8]()

---

## Sequence: A Single Interactive Turn

```mermaid
sequenceDiagram
    participant User as User (TUI)
    participant IM as InteractiveMode
    participant AS as AgentSession
    participant AG as Agent
    participant AL as agentLoop
    participant LLM as LLM Provider (pi-ai)
    participant Tool as Tool (e.g. bash)

    User->>IM: keypress submit
    IM->>AS: session.prompt(text)
    AS->>AG: agent.prompt(userMsg)
    AG->>AL: agentLoop(msgs, context, config)
    AL->>LLM: streamSimple(messages, tools)
    LLM-->>AL: streaming tokens
    AL-->>AG: message_start / message_update
    AG-->>IM: AgentEvent (live token render)
    LLM-->>AL: tool_call block
    AL->>Tool: tool.execute(args, signal)
    Tool-->>AL: AgentToolResult
    AL-->>AG: tool_execution_start/end
    AL->>LLM: streamSimple([...messages, toolResult])
    LLM-->>AL: final assistant message
    AL-->>AG: message_end / agent_end
    AG-->>AS: AgentEvent (agent_end)
    AS->>SessionManager: append messages to JSONL
```

Sources: [packages/agent/src/agent.ts:834-1050](), [packages/agent/src/agent-loop.ts:1-80](), [packages/coding-agent/src/main.ts:317-321]()

---

## Rust / Native Layer

Performance-critical operations bypass the JS runtime. The `crates/pi-natives` crate exposes NAPI bindings consumed by `packages/natives`. Key capabilities:

- **Grep / file search** — `grep-searcher` + `ignore` (respects `.gitignore`)
- **AST-aware editing** — `pi-ast` using `tree-sitter` with grammars for 40+ languages
- **Shell execution** — `pi-shell` wraps `brush-core` (a Rust bash interpreter) for non-PTY shell operations
- **PTY support** — `portable-pty` for interactive shell sessions
- **Syntax highlighting** — `syntect`
- **Tokenization** — `tiktoken-rs` for token counting

The Rust workspace (`Cargo.toml`) uses `lto = "fat"` and `strip = true` in release builds and patches `brush-core` / `brush-builtins` to vendored local forks, pinning shell behavior for the agent's tool harness.

Sources: [Cargo.toml:1-15](), [Cargo.toml]() (workspace.dependencies section), [AGENTS.md]() (package table: `crates/pi-natives`)

---

## Summary

omp is three layers in strict dependency order: the provider-neutral `Agent` core (event stream, tool dispatch, steering/follow-up queues), the `AgentSession` / SDK layer (tool registry, extensions, MCP, LSP, memory, session persistence), and the CLI/mode layer (TUI, RPC, ACP, argument parsing). The LLM provider boundary is the single transform point where `AgentMessage[]` becomes `Message[]` — everything above that boundary can carry app-specific state without polluting the API payload. Because discovery runs parallel at startup and results are frozen into the session, the running session is always working from a consistent snapshot; restarts pick up filesystem changes.

Sources: [packages/agent/src/types.ts:305-315](), [packages/coding-agent/src/sdk.ts:763-787]()

---

## 02. Agent Loop — The Turn Cycle & Harmony Safety Layer

> How the agent loop drives LLM turns, executes tool calls, coerces malformed results, and detects GPT-5 Harmony protocol leakage — the core invariant that keeps session files valid.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/02-agent-loop-the-turn-cycle-harmony-safety-layer.md
- Generated: 2026-05-21T22:00:56.790Z

### Source Files

- `packages/agent/src/agent-loop.ts`
- `packages/agent/src/harmony-leak.ts`
- `packages/agent/src/run-collector.ts`
- `packages/agent/src/telemetry.ts`
- `packages/agent/src/thinking.ts`
- `packages/agent/src/compaction.ts`

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

- [packages/agent/src/agent-loop.ts](packages/agent/src/agent-loop.ts)
- [packages/agent/src/harmony-leak.ts](packages/agent/src/harmony-leak.ts)
- [packages/agent/src/run-collector.ts](packages/agent/src/run-collector.ts)
- [packages/agent/src/telemetry.ts](packages/agent/src/telemetry.ts)
- [packages/agent/src/thinking.ts](packages/agent/src/thinking.ts)
- [packages/agent/src/compaction/index.ts](packages/agent/src/compaction/index.ts)
</details>

# Agent Loop — The Turn Cycle & Harmony Safety Layer

The agent loop is the runtime heart of the `@oh-my-pi/pi-agent` package. It drives the repeated cycle of prompting an LLM, streaming the response, dispatching tool calls, collecting results, and deciding whether to continue — until the model stops requesting tools, no more steering messages arrive, or an abort signal fires. Layered on top of that cycle is the Harmony safety layer, a signal-fusion detector that identifies GPT-5 Codex protocol leakage in model output and either surgically recovers the contaminated tool call or aborts and retries the turn before any malformed content reaches the session file.

Understanding this code is essential for anyone extending the tool surface, tuning retry behaviour, integrating observability, or reasoning about session-file validity guarantees.

---

## Entry Points

There are two public entry points into the loop, both returning an `EventStream<AgentEvent, AgentMessage[]>`:

| Function | When to use |
|---|---|
| `agentLoop(prompts, context, config, signal?, streamFn?)` | Start a new conversation turn; the caller supplies the user messages |
| `agentLoopContinue(context, config, signal?, streamFn?)` | Resume from existing context; last message must be `user` or `toolResult` |

Both emit an `agent_start` event, one or more `turn_start`/`turn_end` pairs, and a terminal `agent_end` event. They share the same inner `runLoop` → `runLoopBody` call path. Two "detailed" wrappers (`agentLoopDetailed`, `agentLoopContinueDetailed`) intercept the `onRunEnd` telemetry hook to expose `AgentRunSummary` and `AgentRunCoverage` alongside the `AgentMessage[]` payload without changing the resolved type of `stream.result()`.

Sources: [packages/agent/src/agent-loop.ts:115-187]()

---

## The Turn Cycle

### Outer and Inner Loops

`runLoopBody` uses a two-level loop structure:

```
outer while(true):
    inner while(hasMoreToolCalls || pendingMessages.length > 0):
        inject pending steering messages
        sync context from live state (config.syncContextBeforeModelCall)
        streamAssistantResponse  → AssistantMessage
        if Harmony interruption → handle recovery or retry
        if stopReason == error|aborted → emit placeholder tool results, end stream
        extract toolCalls from message
        if toolCalls → executeToolCalls → ToolResultMessage[]
        push messages into context
        emit turn_end
        poll getSteeringMessages
    poll getFollowUpMessages
    if none → break
```

The outer loop re-enters only when `getFollowUpMessages()` returns new messages — for example, when the user types while the agent is between turns. The inner loop re-enters as long as the model is returning tool calls or pending steering messages need to be injected before the next LLM call.

Sources: [packages/agent/src/agent-loop.ts:453-605]()

### Streaming an Assistant Response

`streamAssistantResponse` is the single place where `AgentMessage[]` is converted to the wire-level `Message[]` representation the LLM provider expects. It:

1. Applies `config.transformContext` if set (optional context preprocessing, e.g. compaction).
2. Calls `config.convertToLlm(messages)` — the provider adapter boundary.
3. Strips `thinking` blocks for the Cerebras provider via `normalizeMessagesForProvider`.
4. Optionally injects the `_i` (intent) field into tool schemas via `injectIntentIntoSchema`.
5. Resolves a per-request API key and metadata (important for expiring tokens).
6. Starts a `chat` OTEL span and begins streaming via `streamSimple` or the caller-supplied `streamFn`.
7. Races each `iterator.next()` against an abort-sentinel promise — a single listener is registered once and reused for every `next()` call to avoid per-event allocation.
8. Propagates `message_start` / `message_update` / `message_end` events downstream.

If Harmony mitigation is enabled for the model, an additional `AbortController` (`harmonyAbortController`) is threaded into the request signal so the stream can be torn down mid-flight when contamination is detected.

Sources: [packages/agent/src/agent-loop.ts:628-846]()

### Tool-Call Result Coercion

The `coerceToolResult` function is the single boundary where untyped values from `tool.execute()` enter the loop. The problem it guards against is explicit in the source:

> _"Persisting a malformed result corrupts the session file (missing `content` array → crash on reload). We coerce at the single boundary where untyped results enter the agent loop, so every downstream consumer can rely on the type."_

The function checks that `content` is an array, validates each block's `type`, sanitises text via `sanitizeText`, and synthesises a descriptive error message when the result is structurally invalid. Only `text` and `image` block types pass through; anything else is dropped. The `malformed` flag triggers `isError: true` on the resulting `AgentToolResult`.

Sources: [packages/agent/src/agent-loop.ts:69-109]()

### Tool Execution and Concurrency

`executeToolCalls` runs tool calls from a single assistant message. Each tool has a `concurrency` property (`"shared"` | `"exclusive"`); the scheduler chains them accordingly:

```text
records: [A(shared), B(shared), C(exclusive), D(shared)]

A ─────────────────────┐
B ─────────────────────┤  (shared tasks run in parallel)
                        ↓
                   C ──────────  (exclusive: waits for A+B, blocks D)
                                  ↓
                            D ──────  (shared, runs after C)
```

For each tool call, the loop:
1. Extracts and strips the `_i` intent field before passing arguments to `tool.execute()`.
2. Validates arguments via `validateToolArguments` (lenient mode available per tool).
3. Calls `beforeToolCall` — can block execution by returning `{ block: true }`.
4. Calls `tool.execute(id, args, signal, partialResultCallback, toolContext)`.
5. Coerces the raw result through `coerceToolResult`.
6. Calls `afterToolCall` — can rewrite the result.
7. Emits `tool_execution_start`, `tool_execution_update`, and `tool_execution_end` events.
8. Finishes the `execute_tool` OTEL span with status `ok | error | blocked | aborted | skipped`.

Steering interruption (`getSteeringMessages`) is checked after each tool completes. When triggered, `steeringAbortController.abort()` propagates to `toolSignal` and subsequent tools are skipped with a synthetic `"Skipped due to queued user message."` result. The tail sweep after `Promise.allSettled` ensures every tool call in the batch has a corresponding `ToolResultMessage` — a hard API requirement for `tool_use / tool_result` pairing.

Sources: [packages/agent/src/agent-loop.ts:889-1217]()

---

## The Harmony Safety Layer

### Background

GPT-5 (`openai-codex` provider) has a known pathology: under certain token-distribution conditions, its internal `Harmony` protocol headers leak into the visible completion stream. Leaked content takes the form of `<|start|>`, `<|channel|>`, or `to=functions.<name>` markers embedded in text blocks, thinking blocks, or — most dangerously — tool-call argument strings. If persisted verbatim, this content corrupts session files and produces invalid arguments.

`isHarmonyLeakMitigationTarget` enables the detection path for any `openai-codex` provider model by default, so future GPT-5.x variants are covered without enumeration.

Sources: [packages/agent/src/harmony-leak.ts:107-114]()

### Signal Classes

Detection uses a set of named signal classes. A detection fires when:
- Signal class `H` (`<|start|>` / `<|end|>` / `<|channel|>` etc.) appears outside a fenced code block, **or**
- Signal class `M` (`to=functions.<name>`) appears together with **at least one** co-signal — bare `M` is exempted because legitimate documentation, bug reports, and the detection module itself carry that pattern.

| Signal | Meaning |
|---|---|
| `H` | Explicit Harmony delimiter token |
| `M` | `to=functions.<name>` marker |
| `C` | Channel-word adjacency (`analysis`, `commentary`, `assistant`, …) immediately before `M` |
| `G` | Glitch-token adjacency (`changedFiles`, `RTLU`, `Jsii`, `Japgolly`) |
| `S` | Script mismatch: non-Latin run in predominantly ASCII context near `M` |
| `B` | Body-channel cascade: `M … code … M` within 200 chars |
| `R` | Fake-result framing: `M … code_output\nCell N:` within 80 chars |
| `T` | Marker appears after the structurally-valid parse end of the argument string |

Fenced code blocks are pre-scanned in one O(n) pass (`computeFenceRanges`) so matched positions inside fences are excluded before any signal logic runs.

Sources: [packages/agent/src/harmony-leak.ts:14-66](), [packages/agent/src/harmony-leak.ts:137-188]()

### Detection Surfaces

`detectHarmonyLeakInAssistantMessage` walks all content blocks in a completed `AssistantMessage`:

- `text` blocks → `surface: "assistant_text"`
- `thinking` blocks → `surface: "assistant_thinking"`
- `toolCall` blocks → `surface: "tool_arg"` (scans `arguments.input` string directly, falls back to `JSON.stringify` for non-string args)

The first detection is returned; only one needs to fire for the recovery path to activate.

Sources: [packages/agent/src/harmony-leak.ts:191-213]()

### Recovery Strategies

The agent loop catches `HarmonyLeakInterruption` and selects a strategy based on whether the detection is recoverable:

```
HarmonyLeakInterruption thrown
    └── err.recovered?
         ├── YES (truncate-and-resume):
         │    if harmonyTruncateResumeCount >= 2 → escalate (throw)
         │    else: use recovered.message, increment counter
         │    emit audit event (action: "truncate_resume")
         └── NO (abort-and-retry):
              if harmonyRetryAttempt >= 2 → escalate (throw)
              else: increment counter, continue inner loop (re-prompt)
              emit audit event (action: "abort_retry")
```

**Truncate-and-resume** is available for `edit` and `eval` tools. The contaminated tool-call input is truncated at the line boundary just before the first signal and a `*** Abort` sentinel is appended. The `edit` tool's hashline DSL parser recognises this sentinel; `eval`'s cell parser accepts it unconditionally. The encrypted `providerPayload` (Codex reasoning blob) is dropped from the recovered message so it cannot carry the leak forward. The recovered `AssistantMessage` contains only the cleaned tool call and is injected into the loop as if the model had returned it.

**Abort-and-retry** applies to all other surfaces. The turn is discarded and the inner loop simply continues to request a new LLM response. Temperature is nudged up by `+0.05` on each retry attempt to shift the model away from the problematic token distribution.

Both strategies cap out at 2 attempts; exceeding the cap throws an escalation error with a human-readable message including the signal labels.

Every recovery or escalation event is reported to `config.onHarmonyLeak` via `createHarmonyAuditEvent`, which builds a privacy-safe `HarmonyAuditEvent` with a redacted 64-char preview (`removedPreview`), a SHA-8 of the removed content, and the full blob only when `OMP_HARMONY_DEBUG=1`.

Sources: [packages/agent/src/agent-loop.ts:489-529](), [packages/agent/src/harmony-leak.ts:227-261](), [packages/agent/src/harmony-leak.ts:283-303]()

---

## Run Collector and Telemetry

### Span Hierarchy

The loop emits a three-level OpenTelemetry span tree per `agentLoop` invocation:

```
invoke_agent {agent.name}
├── chat {model}          ← one per LLM call
├── execute_tool {name}   ← one per tool call
└── ...
```

Activation is fully opt-in. When `config.telemetry` is unset, every helper short-circuits. When set but no OTEL SDK is registered, `@opentelemetry/api` returns a no-op tracer.

Sources: [packages/agent/src/telemetry.ts:8-24]()

### AgentRunCollector

One `AgentRunCollector` instance lives per `AgentTelemetry` handle (constructed once per `agentLoop` call in `resolveTelemetry`). It buffers `ChatRecord` and `ToolRecord` entries as spans finish, then produces an immutable `AgentRunSummary` + `AgentRunCoverage` snapshot on demand.

Key design decisions:
- Span references are used as `WeakMap`-like keys (symbol properties on the span object) so memory is bounded and there is no cross-invocation leakage.
- Methods are non-throwing by contract: telemetry must never convert a successful run into a failed one.
- The `markRunEnded()` guard is idempotent; it coordinates between the success path (`buildAgentEndEvent`) and the error path (`finishInvokeAgentSpan`'s finally block) so `onRunEnd` fires exactly once.
- Tools that never produce a span (pre-run interrupt, tail sweep) are recorded via `recordOrphanTool` so `coverage.toolsInvoked` remains accurate.

`AgentRunCoverage` separately tracks `toolsAvailable` (declared by the config), `toolsInvoked` (actually called during the run), `toolsUnused` (available but never called), `modelsUsed`, and `providersUsed`. All sets are sorted and deduped so coverage values are stable for diffing and assertion in tests.

Sources: [packages/agent/src/run-collector.ts:147-302](), [packages/agent/src/run-collector.ts:418-433]()

---

## State Ownership and Boundaries

```text
AgentContext (caller-owned, mutated in place)
  messages[]   ← loop appends AssistantMessage + ToolResultMessage each turn
  tools[]      ← declared once; normalizeTools adds intent fields per call
  systemPrompt ← stable across turns

AgentLoopConfig (caller-owned, read-only from loop's perspective)
  convertToLlm       ← wire-format adapter (provider boundary)
  transformContext   ← optional pre-LLM message transform (e.g. compaction)
  getSteeringMessages / getFollowUpMessages ← hooks for live user input
  beforeToolCall / afterToolCall            ← intercept hooks
  onHarmonyLeak                             ← audit sink
  telemetry                                 ← OTEL config + run collector

EventStream<AgentEvent, AgentMessage[]>
  ← one-way push channel; loop → consumer
  ← resolves to AgentMessage[] (new messages only) on agent_end
```

The loop never mutates `AgentLoopConfig`. It does mutate `currentContext.messages` in place, which is a shallow copy of the caller's `context.messages` array (plus the new prompts). The caller's original `context.messages` array is not modified.

Sources: [packages/agent/src/agent-loop.ts:124-146]()

---

## Failure Modes

| Condition | Behaviour |
|---|---|
| `stopReason === "error"` or `"aborted"` | Placeholder `ToolResultMessage` created for each unanswered tool call; `agent_end` emitted immediately |
| Tool not found | `isError: true` result with `"Tool X not found"` message; run continues |
| `tool.execute()` returns invalid content | `coerceToolResult` normalises to error result; session file stays valid |
| `beforeToolCall` returns `{ block: true }` | `ToolCallBlockedError` thrown; span status set to `"blocked"` |
| Harmony leak, recoverable tool | Truncate-and-resume up to 2 times, then escalation error |
| Harmony leak, irrecoverable | Abort-and-retry up to 2 times (+0.05 temperature nudge), then escalation error |
| Abort signal fired mid-stream | `emitAbortedAssistantMessage` synthesises an `AssistantMessage` with `stopReason: "aborted"` |
| Steering interrupt during tool batch | `steeringAbortController.abort()` propagates to `toolSignal`; in-flight non-`nonAbortable` tools receive the signal; remaining tools get skipped results |

The tail sweep after `Promise.allSettled(tasks)` guarantees that every tool call in the batch produces a `ToolResultMessage`, maintaining the API-required `tool_use / tool_result` pairing even when the loop takes the abort or skip path.

Sources: [packages/agent/src/agent-loop.ts:533-558](), [packages/agent/src/agent-loop.ts:1200-1216]()

---

## Summary

The agent loop in `packages/agent/src/agent-loop.ts` drives LLM turns through a two-level while-loop, converts `AgentMessage[]` to provider wire format at a single boundary inside `streamAssistantResponse`, and coerces all tool results through `coerceToolResult` before they touch context state — keeping the session file structurally valid regardless of what third-party tools return. The Harmony safety layer in `packages/agent/src/harmony-leak.ts` adds a multi-signal detector that classifies GPT-5 Codex protocol leakage into recoverable (truncate-and-resume via the `edit`/`eval` sentinel mechanism) and non-recoverable (abort-and-retry with temperature nudge) categories, each capped at two attempts before escalating to a hard error. Run-level aggregation is handled non-throwingly by `AgentRunCollector` in `packages/agent/src/run-collector.ts`, which snapshots into a stable `AgentRunSummary` + `AgentRunCoverage` pair at the `agent_end` event boundary.

Sources: [packages/agent/src/agent-loop.ts:1184-1216]()

---

## 03. Native Layer — Rust Crates & N-API Addon

> The ~27k-line Rust underbelly: pi-natives (N-API cdylib), pi-shell (embedded bash/PTY), pi-ast (tree-sitter summarizer), pi-iso (workspace isolation), and how each module eliminates fork/exec on the hot path.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/03-native-layer-rust-crates-n-api-addon.md
- Generated: 2026-05-21T22:02:22.372Z

### Source Files

- `crates/pi-natives/Cargo.toml`
- `crates/pi-natives/build.rs`
- `crates/pi-shell/Cargo.toml`
- `crates/pi-ast/Cargo.toml`
- `crates/pi-iso/Cargo.toml`
- `packages/natives`

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

- [crates/pi-natives/Cargo.toml](crates/pi-natives/Cargo.toml)
- [crates/pi-natives/build.rs](crates/pi-natives/build.rs)
- [crates/pi-natives/src/lib.rs](crates/pi-natives/src/lib.rs)
- [crates/pi-natives/src/shell.rs](crates/pi-natives/src/shell.rs)
- [crates/pi-natives/src/pty.rs](crates/pi-natives/src/pty.rs)
- [crates/pi-natives/src/grep.rs](crates/pi-natives/src/grep.rs)
- [crates/pi-shell/Cargo.toml](crates/pi-shell/Cargo.toml)
- [crates/pi-shell/src/lib.rs](crates/pi-shell/src/lib.rs)
- [crates/pi-shell/src/shell.rs](crates/pi-shell/src/shell.rs)
- [crates/pi-shell/src/minimizer.rs](crates/pi-shell/src/minimizer.rs)
- [crates/pi-shell/src/minimizer/engine.rs](crates/pi-shell/src/minimizer/engine.rs)
- [crates/pi-ast/Cargo.toml](crates/pi-ast/Cargo.toml)
- [crates/pi-ast/src/lib.rs](crates/pi-ast/src/lib.rs)
- [crates/pi-ast/src/summary.rs](crates/pi-ast/src/summary.rs)
- [crates/pi-ast/src/language/mod.rs](crates/pi-ast/src/language/mod.rs)
- [crates/pi-iso/Cargo.toml](crates/pi-iso/Cargo.toml)
- [crates/pi-iso/src/lib.rs](crates/pi-iso/src/lib.rs)
- [crates/pi-iso/src/apfs.rs](crates/pi-iso/src/apfs.rs)
- [crates/pi-iso/src/overlayfs.rs](crates/pi-iso/src/overlayfs.rs)
- [packages/natives/native/index.js](packages/natives/native/index.js)
- [packages/natives/native/loader-state.js](packages/natives/native/loader-state.js)
</details>

# Native Layer — Rust Crates & N-API Addon

The oh-my-pi toolchain offloads performance-critical work to a Rust layer that surfaces as a single compiled N-API addon (`pi_natives.*.node`) loaded by the Node/Bun JS runtime. Rather than shelling out to `grep`, `bash`, or filesystem utilities on the hot path, the JS frontend calls directly into the in-process Rust binary, eliminating fork/exec latency for operations that happen hundreds of times per session.

This page covers the four Rust library crates that make up the native layer — **pi-natives** (the N-API cdylib), **pi-shell** (embedded bash interpreter), **pi-ast** (tree-sitter code summarizer), and **pi-iso** (workspace isolation PAL) — plus the JS loader in `packages/natives` that validates and attaches the compiled addon at startup.

---

## Crate Structure at a Glance

```text
┌─────────────────────────────────────────────────────────────┐
│                 packages/natives (JS/TS)                    │
│   native/index.js  ──loadNative()──  loader-state.js       │
└─────────────────────────┬───────────────────────────────────┘
                          │ require("pi_natives.<tag>.node")
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                crates/pi-natives  (cdylib)                  │
│  appearance · ast · clipboard · fd · glob · grep · html    │
│  highlight · iso · keys · power · ps · pty · shell · text  │
│  sixel · summary · task · tokens · workspace               │
└────┬──────────────┬──────────────┬───────────────────────── ┘
     │              │              │
     ▼              ▼              ▼
crates/pi-shell  crates/pi-ast  crates/pi-iso
 (brush embed)  (tree-sitter)  (CoW / overlay)
```

Each inner crate has no `[lib] crate-type` override — they are normal `rlib` dependencies. Only `pi-natives` declares `crate-type = ["cdylib"]`, making it the single N-API boundary.

Sources: [crates/pi-natives/Cargo.toml:9-10]()

---

## pi-natives — The N-API cdylib

### Module Inventory

`pi-natives` re-exports every capability through a flat module tree. The full list from `src/lib.rs` is:

| Module | Responsibility |
|---|---|
| `appearance` | macOS dark-mode observation |
| `ast` | `ast-grep` structural search/edit |
| `clipboard` | Read/write system clipboard (arboard) |
| `fd` | File-descriptor utilities |
| `fs_cache` | Filesystem scan cache (`DashMap`-backed) |
| `glob` | Glob file discovery (globset + ignore) |
| `grep` | Ripgrep-backed content search |
| `highlight` | Syntax highlighting (syntect) |
| `html` | HTML-to-Markdown conversion |
| `keys` | Terminal key-sequence parsing |
| `iso` | N-API shim for `pi-iso` backends |
| `power` | macOS power-assert / display-sleep prevention |
| `prof` | Flame-graph profiling (inferno) |
| `ps` | Process listing |
| `pty` | PTY-backed interactive sessions (portable-pty) |
| `shell` | Brush-based shell execution shim |
| `sixel` | SIXEL image encoding for terminal display |
| `summary` | Code structural summary shim for `pi-ast` |
| `task` | Cancellation / abort token wiring for N-API |
| `text` | ANSI-aware width measurement and wrapping |
| `tokens` | Tiktoken token counting |
| `workspace` | Workspace listing |

Sources: [crates/pi-natives/src/lib.rs:24-50]()

### Version Sentinel

The addon exports a single zero-cost function whose JS name encodes the package version:

```rust
#[napi(js_name = "__piNativesV15_2_1")]
pub const fn pi_natives_version_sentinel() {}
```

The JS loader checks for the presence of `__piNativesV{major}_{minor}_{patch}` before handing the binding to callers. If the name is absent (e.g., a locked Windows file left a stale `.node` from a previous install), the loader surfaces a clear load-time error instead of a silent `<sym> is not a function` crash. The sentinel name is bumped by `scripts/release.ts` on every release.

Sources: [crates/pi-natives/src/lib.rs:54-71]()

### Build Script

`build.rs` does two things: calls `napi_build::setup()` to wire N-API header generation, and concatenates all per-tool filter definition TOML files under `src/shell/minimizer/defs/*.toml` into a single `OUT_DIR/builtin_filters.toml` that gets embedded at compile time. Each `.toml` file is watched independently so only relevant filter changes trigger a rebuild.

Sources: [crates/pi-natives/build.rs:8-64]()

### JS Loader (`packages/natives`)

The loader in `packages/natives/native/loader-state.js` resolves which `.node` file to load through several stages:

1. **Compiled-binary detection**: checks for embedded addon (Bun standalone binaries), `PI_COMPILED` env var, or bunfs URL patterns.
2. **Variant selection**: on x64, tries `pi_natives.<tag>-modern.node` (AVX2), then `pi_natives.<tag>-baseline.node`, then the default.
3. **Windows staging**: on Windows, mirrors the `.node` from `node_modules` to a version-pinned path in `~/.omp/natives/<version>/` so concurrent processes never lock each other's file.
4. **Sentinel validation**: requires the version-matching export to exist before returning the binding.

Supported platform tags are: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`.

Sources: [packages/natives/native/loader-state.js:33-148]()

---

## pi-shell — Embedded Bash Interpreter

### Design: No Fork/Exec for Shell Builtins

`pi-shell` embeds the [brush](https://github.com/reubeno/brush) Bash-compatible interpreter as a Rust library. Shell commands are executed by calling into `BrushShell::run_string` in-process rather than forking `/bin/bash`. This removes the process-creation round trip from the hot path for short shell commands and enables reliable cancellation.

The public API surface (`src/lib.rs`) re-exports `Shell`, `execute_shell`, `execute_shell_streams`, `ShellOptions`, `ShellRunOptions`, and `ShellRunResult`.

Sources: [crates/pi-shell/src/lib.rs:1-13]()

### Session Model

`Shell` holds a single `Arc<TokioMutex<Option<ShellSessionCore>>>`. The first `Shell::run` call boots a fresh `BrushShell` via `create_session` and caches it. Subsequent calls reuse the same session (state, environment, working directory). The session is evicted when the command's `ExecutionControlFlow` is not `Normal` (e.g., `ExitShell`).

```rust
pub struct Shell {
    session:     Arc<TokioMutex<Option<ShellSessionCore>>>,
    abort_state: ShellAbortState,
    config:      ShellConfig,
}
```

Sources: [crates/pi-shell/src/shell.rs:123-127](), [crates/pi-shell/src/shell.rs:1154-1162]()

### Session Initialization

`create_session` builds a brush shell with no profile or rc loading (`ProfileLoadBehavior::Skip`, `RcLoadBehavior::Skip`), copies the parent-process environment while explicitly filtering out shell-internals that would pollute the embedded shell (`BASH_FUNC_*%%`, `PS1`, `HISTFILE`, `SHLVL`, and about 40 others), disables the `exec` and `suspend` builtins, and registers custom `sleep` and `timeout` builtins that honour the Tokio cancellation token. On Windows, it additionally deduplicates PATH entries.

Sources: [crates/pi-shell/src/shell.rs:471-551](), [crates/pi-shell/src/shell.rs:1096-1152]()

### Output Streaming and Cancellation

For each command, `run_shell_command` creates an `os_pipe` and redirects both stdout and stderr through it. A concurrent Tokio task drains the pipe, emitting UTF-8-decoded chunks to an `mpsc::UnboundedSender<String>`. On Unix, the pipe file descriptor is set to `O_NONBLOCK` and wrapped in a `tokio::io::unix::AsyncFd` so the drain loop can park on readiness without blocking the runtime thread.

Cancellation is cooperative at three levels:
1. A `CancellationToken` passed into `BrushShell::run_string` stops the brush interpreter.
2. A cancel bridge task wakes when the token fires and cancels the reader.
3. A process-cancel bridge issues SIGTERM (wave 0, 75 ms grace), then SIGKILL (waves 1-2, 150 ms) to any descendant PIDs that the brush interpreter spawned as external processes.

Sources: [crates/pi-shell/src/shell.rs:596-681]()

### Output Minimizer

The minimizer is an opt-in post-processor that rewrites verbose command output before it reaches the JS caller. The engine is inert unless a `MinimizerConfig` is supplied; compound or piped commands are always passed through unchanged.

```rust
pub enum MinimizerMode {
    None,
    WholeCommand,
}
```

For single commands, `engine::mode_for` runs the detector and, if a registered filter matches, switches to `WholeCommand` mode: the output is buffered (up to `max_capture_bytes`, default 4 MiB) and the matching filter rewrites it after the command exits. The original text is returned alongside the minimized text so the JS session layer can persist the original via its `ArtifactManager`.

Built-in filter definitions are `.toml` files under `src/shell/minimizer/defs/` that get concatenated and embedded at build time. Filters exist for `git`, `cargo`, `bun`, `go`, `docker`, `gh`, Python, Ruby, lint tools, cloud CLIs, and more.

Sources: [crates/pi-shell/src/minimizer.rs:1-6](), [crates/pi-shell/src/minimizer/engine.rs:17-43]()

### PTY Sessions

When the JS caller needs a full interactive terminal (e.g., for commands that require a TTY), `pi-natives/src/pty.rs` creates a `portable_pty` session instead. The `PtySession` N-API class exposes `start`, `writeInput`, `resize`, and `kill`. Output is streamed via a N-API threadsafe function. Control messages (input, resize, kill) are delivered through a `std::sync::mpsc` channel to a dedicated reader thread.

Sources: [crates/pi-natives/src/pty.rs:1-80]()

---

## pi-ast — Tree-sitter Code Summarizer

### Language Registry

`pi-ast` bundles over 50 tree-sitter grammars as workspace dependencies (Rust, TypeScript, Python, Go, Java, C/C++, Kotlin, Swift, Dart, Zig, Nix, Haskell, TLA+, Odin, Verilog, and many others). Each grammar is exposed through the `SupportLang` enum. The `language/mod.rs` module implements the `ast-grep-core` `Language` and `LanguageExt` traits for every entry using a macro that keeps the boilerplate minimal.

Sources: [crates/pi-ast/Cargo.toml:18-75](), [crates/pi-ast/src/language/mod.rs:20-45]()

### Structural Summarization

`pi-ast::summary::summarize_code` is the main entry point used by the `summarizeCode` N-API export. Given source text and an optional language hint or file path, it:

1. Resolves the tree-sitter grammar from the language alias or file extension.
2. Parses the source and short-circuits to a passthrough result on syntax errors.
3. Walks the AST, building a list of `LineSpan` ranges to elide — function bodies longer than `min_body_lines` (default 4) and block comments longer than `min_comment_lines` (default 6).
4. Returns a `SummaryResult` carrying a `Vec<SummarySegment>` where each segment is tagged `"kept"` or `"elided"`.

```rust
pub struct SummaryResult {
    pub language:    Option<String>,
    pub parsed:      bool,
    pub elided:      bool,
    pub total_lines: u32,
    pub segments:    Vec<SummarySegment>,
}
```

The caller can reconstruct a token-efficient representation of any source file by emitting kept segments and substituting a placeholder comment for elided spans.

Sources: [crates/pi-ast/src/summary.rs:15-53](), [crates/pi-ast/src/summary.rs:61-80]()

---

## pi-iso — Workspace Isolation PAL

### Purpose

When the toolchain needs to run a task in an isolated copy of the workspace (e.g., an agent that may mutate files), `pi-iso` provides a platform-abstracted copy-on-write mechanism. The goal is to give the task a writable view of the workspace without a full deep copy — only blocks actually written are duplicated.

### Backend Hierarchy

`pi-iso` defines the `IsolationBackend` trait and ships eight implementations:

| `BackendKind` | Mechanism | Platform |
|---|---|---|
| `Apfs` | `clonefile(2)` recursive reflink | macOS |
| `Btrfs` | `btrfs subvolume snapshot` | Linux + btrfs |
| `Zfs` | ZFS dataset snapshot + clone | Linux/FreeBSD/macOS + ZFS |
| `LinuxReflink` | `FICLONE` ioctl per-file reflink | Linux (btrfs, XFS, bcachefs) |
| `Overlayfs` | kernel `overlay` fs + `fuse-overlayfs` fallback | Linux |
| `WindowsBlockClone` | `FSCTL_DUPLICATE_EXTENTS_TO_FILE` | Windows (NTFS/ReFS) |
| `Projfs` | Windows Projected File System | Windows |
| `Rcopy` | `git worktree` or recursive copy | All (universal fallback) |

The auto-selection order is platform-specific:
- **macOS**: `Apfs → Zfs → Rcopy`
- **Linux**: `Btrfs → Zfs → LinuxReflink → Overlayfs → Rcopy`
- **Windows**: `WindowsBlockClone → Projfs → Rcopy`

Sources: [crates/pi-iso/src/lib.rs:47-140]()

### Backend Trait

```rust
pub trait IsolationBackend: Send + Sync {
    fn kind(&self) -> BackendKind;
    fn probe(&self) -> ProbeResult;
    fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()>;
    fn stop(&self, merged: &Path) -> IsoResult<()>;
    async fn diff(&self, lower: &Path, merged: &Path) -> IsoResult<Diff>;
}
```

`start`/`stop` are synchronous because the platform primitives they wrap (`mount`, `clonefile`, `PrjStartVirtualizing`) are blocking syscalls; callers are expected to drive them from `spawn_blocking`. `diff` is async because it may walk large trees or spawn `git`.

Every backend is compiled into every build as a unit struct. On platforms where it isn't native, `probe()` returns `available: false` and `start` returns `IsoError::Unavailable` — so the N-API shim can mirror the user's `task.isolation.mode` setting without platform-conditional code on the JS side.

Sources: [crates/pi-iso/src/lib.rs:211-246](), [crates/pi-iso/src/lib.rs:253-277]()

### Diff Surface

For git-backed workspaces (all agent tasks in omp), `IsolationBackend::diff` delegates to `git diff`, producing output that is byte-identical to what `git apply` consumes. For non-git trees (only reachable via `Rcopy`), it walks both trees using `(size, mtime)` as a cheap pre-filter before doing a content comparison.

Sources: [crates/pi-iso/src/lib.rs:1-20]()

### Fallback Resolution

The `resolve(preferred: Option<BackendKind>)` function encodes the full probe-and-fallback sequence: it checks the caller's preferred backend first, then iterates the platform's auto-order list, skipping backends whose `probe()` reports unavailable. The result is a `Resolution` struct carrying the winning `kind`, a full `candidates` list for retry-on-`IsoError::Unavailable`, and a `fell_back` flag.

Sources: [crates/pi-iso/src/lib.rs:316-373]()

---

## Hot-Path Invariants

The no-fork guarantee holds because:

1. **Shell execution** (`pi-shell`) calls `BrushShell::run_string` — an in-process Rust function call — not `execvp`. External commands launched by the shell do fork child processes, but those are controlled by the tool and rare on the hot path.
2. **Search** (`pi-natives::grep`) calls the ripgrep library crates (`grep-regex`, `grep-searcher`, `ignore`) directly with `rayon` parallelism, never spawning `rg`.
3. **AST operations** (`pi-ast`) run the tree-sitter parser in-process with zero child processes.
4. **Isolation setup** (`pi-iso`) uses OS-level CoW syscalls (`clonefile`, `FICLONE`, `mount overlay`) that are single syscalls, not child programs — except `Rcopy` which may call `git worktree add` as a one-time setup.

The minimizer is the only component that intentionally defers work (buffering output) to save downstream token cost, and it is strictly opt-in with an explicit byte cap to bound peak memory.

Sources: [crates/pi-natives/src/lib.rs:17-19](), [crates/pi-shell/src/shell.rs:569-588]()

---

## 04. Four Run Modes — Interactive, Print, RPC, and ACP

> Same engine, four wrappers: the TUI interactive mode, one-shot print mode, NDJSON RPC over stdio, and ACP/JSON-RPC for editors. Understand which mode owns the I/O boundary, what framing it uses, and when tool calls route through the host.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/04-four-run-modes-interactive-print-rpc-and-acp.md
- Generated: 2026-05-21T21:59:21.259Z

### Source Files

- `packages/coding-agent/src/main.ts`
- `packages/coding-agent/src/modes/index.ts`
- `packages/coding-agent/src/modes/interactive-mode.ts`
- `packages/coding-agent/src/modes/print-mode.ts`
- `packages/coding-agent/src/modes/rpc`
- `packages/coding-agent/src/modes/acp`

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

- [packages/coding-agent/src/main.ts](packages/coding-agent/src/main.ts)
- [packages/coding-agent/src/modes/index.ts](packages/coding-agent/src/modes/index.ts)
- [packages/coding-agent/src/modes/interactive-mode.ts](packages/coding-agent/src/modes/interactive-mode.ts)
- [packages/coding-agent/src/modes/print-mode.ts](packages/coding-agent/src/modes/print-mode.ts)
- [packages/coding-agent/src/modes/rpc/rpc-mode.ts](packages/coding-agent/src/modes/rpc/rpc-mode.ts)
- [packages/coding-agent/src/modes/rpc/rpc-types.ts](packages/coding-agent/src/modes/rpc/rpc-types.ts)
- [packages/coding-agent/src/modes/rpc/host-tools.ts](packages/coding-agent/src/modes/rpc/host-tools.ts)
- [packages/coding-agent/src/modes/acp/acp-mode.ts](packages/coding-agent/src/modes/acp/acp-mode.ts)
- [packages/coding-agent/src/modes/acp/acp-agent.ts](packages/coding-agent/src/modes/acp/acp-agent.ts)
- [packages/coding-agent/src/modes/acp/acp-client-bridge.ts](packages/coding-agent/src/modes/acp/acp-client-bridge.ts)
- [packages/coding-agent/src/cli/args.ts](packages/coding-agent/src/cli/args.ts)
</details>

# Four Run Modes — Interactive, Print, RPC, and ACP

The coding agent exposes a single shared `AgentSession` engine behind four distinct I/O wrappers, each with a different framing contract, ownership model, and surface for host-supplied tools. Choosing the right mode is not just a UI preference — it determines which process owns the terminal, how bytes flow between the agent and its caller, and whether tool calls can be delegated back to an external host.

The mode is selected with `--mode <value>` at the CLI. The valid values are `text`, `json`, `rpc`, `rpc-ui`, and `acp`. When no `--mode` flag is given and no `--print`/`-p` flag is present, the process falls into interactive (TUI) mode automatically. Piped stdin input without an explicit `--print` also triggers a one-shot print run via the `autoPrint` path.

Sources: [packages/coding-agent/src/cli/args.ts:10](packages/coding-agent/src/cli/args.ts), [packages/coding-agent/src/main.ts:797-799](packages/coding-agent/src/main.ts)

---

## Mode Selection and Dispatch

`runRootCommand` in `main.ts` is the single dispatch point. After creating an `AgentSession`, it branches on the resolved mode:

```
parsed.mode
  "acp"         → runAcpMode (session factory, not a pre-built session)
  "rpc" / "rpc-ui" → runRpcMode(session, setToolUIContext?)
  isInteractive   → runInteractiveMode(session, …)
  else (text/json)→ runPrintMode(session, { mode, … })
```

ACP is special: it receives a **session factory** (`createAcpSessionFactory`) instead of a ready session, because it manages multiple concurrent sessions over the lifetime of a single connection. All other modes receive a single pre-created `AgentSession`.

Sources: [packages/coding-agent/src/main.ts:932-1026](packages/coding-agent/src/main.ts)

### RPC-specific setting overrides

When the mode is `rpc`, `rpc-ui`, or `acp`, the startup code calls `applyRpcDefaultSettingOverrides`, which resets a fixed list of settings (async jobs, todo subsystem, bash auto-backgrounding, task isolation, memory backends) to their factory defaults. This prevents operator-configured preferences from leaking into headless embedding contexts that have not opted into those features.

Sources: [packages/coding-agent/src/main.ts:87-115](packages/coding-agent/src/main.ts)

---

## Interactive Mode

Interactive mode is the full TUI experience. It is activated when neither `--print` nor `--mode` is provided and stdin is a TTY.

### TUI ownership

The `InteractiveMode` class owns a `TUI` instance (from `@oh-my-pi/pi-tui`) along with a hierarchy of `Container` and component objects: `chatContainer`, `statusContainer`, `todoContainer`, `editor`, `statusLine`, widget areas above and below the editor, and transient overlays for tool execution, bash, and Python. The class implements `InteractiveModeContext`, an interface that the session and controllers use to push rendering updates without taking a direct dependency on the TUI.

Sources: [packages/coding-agent/src/modes/interactive-mode.ts:185-244](packages/coding-agent/src/modes/interactive-mode.ts)

### Input loop

`runInteractiveMode` in `main.ts` constructs the `InteractiveMode`, calls `mode.init()`, optionally fires an `initialMessage` prompt, then enters an unbounded loop:

```typescript
while (true) {
    const input = await mode.getUserInput();
    await submitInteractiveInput(mode, session, input);
}
```

`submitInteractiveInput` routes to `session.prompt()` or `session.promptCustomMessage()` based on whether `input.customType` is set, and calls `mode.checkShutdownRequested()` after each turn.

Sources: [packages/coding-agent/src/main.ts:317-321](packages/coding-agent/src/main.ts), [packages/coding-agent/src/main.ts:133-167](packages/coding-agent/src/main.ts)

### What interactive mode owns uniquely

- Full terminal title management (`pushTerminalTitle`, `setSessionTerminalTitle`)
- STT (speech-to-text) controller
- Loop-limit and goal-mode state
- Plan-mode UI (plan file approval dialogs)
- Hook editor/selector/input overlay components
- Version-check notification banner
- Changelog display on first launch after upgrade

None of these exist in the other modes.

---

## Print Mode

Print mode is the one-shot, process-and-exit wrapper. It handles both `--print` / `-p` (text output) and `--mode json` (NDJSON event stream).

### Activation

`runRootCommand` sets `autoPrint = true` when stdin is not a TTY and no explicit `--mode` or `--print` flag is present. Both `autoPrint` and `--print` result in the `runPrintMode` call path.

Sources: [packages/coding-agent/src/main.ts:797-798](packages/coding-agent/src/main.ts)

### Output contract

```
--mode text (default)   → only the final assistant text block is written to stdout
--mode json             → every AgentSessionEvent is serialized as a JSON line (NDJSON)
```

In `text` mode, `runPrintMode` reads `session.state.messages` after all prompts complete and writes the last assistant message's text content. In `json` mode, a session header is emitted first, then a `session.subscribe` callback writes every event as it fires.

Error cases: if the final assistant message has `stopReason === "error"` or `"aborted"` (and is not a silent-abort plan compaction transition), the error text goes to stderr and the process exits with code 1.

Sources: [packages/coding-agent/src/modes/print-mode.ts:32-121](packages/coding-agent/src/modes/print-mode.ts)

### Extension initialization in print mode

Print mode still calls `initializeExtensions(session, …)`, passing lightweight error reporters to stderr. There is no UI context — extension `sendMessage`/`sendUserMessage` failures are logged to stderr as plain strings and the process continues.

Sources: [packages/coding-agent/src/modes/print-mode.ts:43-52](packages/coding-agent/src/modes/print-mode.ts)

---

## RPC Mode

RPC mode turns the agent into a long-running subprocess controlled over NDJSON on stdin/stdout. It is intended for embedding the agent inside editors, IDE extensions, or other processes that want to drive it programmatically.

### Framing

```
stdin  → newline-delimited JSON RpcCommand objects
stdout → newline-delimited JSON (RpcResponse | AgentSessionEvent | RpcExtensionUIRequest | host-tool frames)
```

On startup, `runRpcMode` immediately writes `{"type":"ready"}\n` so the parent process can detect when the server is listening. It also sets `process.env.PI_NOTIFICATIONS = "off"` to suppress any OSC/BEL sequences that would corrupt the JSON channel.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:172-179](packages/coding-agent/src/modes/rpc/rpc-mode.ts)

### Command dispatch

`readJsonl(Bun.stdin.stream())` drives an async for-loop. Each parsed object is classified:

| Frame type | Handler |
|---|---|
| `extension_ui_response` | Resolves a pending `RpcExtensionUIContext` promise |
| `host_tool_result` | `RpcHostToolBridge.handleResult` |
| `host_tool_update` | `RpcHostToolBridge.handleUpdate` |
| `host_uri_result` | `RpcHostUriBridge.handleResult` |
| Everything else | `handleCommand` → `output(response)` |

The `prompt` command is fire-and-forget — it calls `session.prompt()` without awaiting and returns `{success: true}` immediately, so events stream back asynchronously. All other commands (`steer`, `follow_up`, `abort`, `get_state`, etc.) are awaited before the response is written.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:807-845](packages/coding-agent/src/modes/rpc/rpc-mode.ts), [packages/coding-agent/src/modes/rpc/rpc-mode.ts:462-473](packages/coding-agent/src/modes/rpc/rpc-mode.ts)

### RPC command surface

```
Prompting:   prompt · steer · follow_up · abort · abort_and_prompt · new_session
State:       get_state · set_todos · set_host_tools · set_host_uri_schemes
Model:       set_model · cycle_model · get_available_models
Thinking:    set_thinking_level · cycle_thinking_level
Queue modes: set_steering_mode · set_follow_up_mode · set_interrupt_mode
Compaction:  compact · set_auto_compaction
Retry:       set_auto_retry · abort_retry
Bash:        bash · abort_bash
Session:     get_session_stats · export_html · switch_session · branch ·
             get_branch_messages · get_last_assistant_text · set_session_name · handoff
Messages:    get_messages
Login:       get_login_providers · login
```

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:19-75](packages/coding-agent/src/modes/rpc/rpc-types.ts)

### Host tools

The host process can inject custom tools into the agent's tool registry via the `set_host_tools` command. Each tool definition supplies a name, description, and JSON Schema for parameters. When the agent calls a host tool, the server emits a `host_tool_call` frame on stdout; the host executes it and replies with a `host_tool_result` (or streams partial results via `host_tool_update`). `RpcHostToolBridge` manages pending call state and propagates cancellations when the RPC client disconnects.

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:270-307](packages/coding-agent/src/modes/rpc/rpc-types.ts), [packages/coding-agent/src/modes/rpc/host-tools.ts:1-60](packages/coding-agent/src/modes/rpc/host-tools.ts)

### Host URI schemes

The `set_host_uri_schemes` command registers custom URI schemes (e.g. `db://`, `notion://`). When the agent's read or write tools encounter a matching URI, the server emits a `host_uri_request` frame; the host resolves it and replies with `host_uri_result`. This lets the host expose arbitrary read/write backends without implementing a full MCP server.

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:313-362](packages/coding-agent/src/modes/rpc/rpc-types.ts)

### Extension UI over RPC

The `RpcExtensionUIContext` class (defined inline in `rpc-mode.ts`) implements the `ExtensionUIContext` interface. UI primitives — `select`, `confirm`, `input`, `editor`, `notify`, `setStatus`, `setWidget`, `setTitle` — are translated into `extension_ui_request` frames emitted on stdout with a unique snowflake `id`. The host replies with an `extension_ui_response` frame carrying the same `id` to settle the waiting promise. `setFooter`, `setHeader`, `custom`, and raw terminal input are stubs.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:209-426](packages/coding-agent/src/modes/rpc/rpc-mode.ts)

### `rpc-ui` variant

`--mode rpc-ui` behaves identically to `rpc` except `setToolUIContext` is wired into the session, giving extension tool calls (the `ask` tool, permission dialogs) access to the same `RpcExtensionUIContext`. Plain `--mode rpc` does not pass `setToolUIContext`, leaving those paths unrouted.

Sources: [packages/coding-agent/src/main.ts:974-975](packages/coding-agent/src/main.ts)

---

## ACP Mode

ACP (Agent Client Protocol) mode implements the `@agentclientprotocol/sdk` wire protocol, a JSON-RPC-like framing designed for editor integrations (Zed, VS Code extensions, etc.). Unlike RPC mode — which manages one session per process — ACP mode manages **multiple named sessions** over a single long-lived connection.

### Transport

`runAcpMode` builds an NDJSON duplex transport from the swapped stdin/stdout pair and instantiates an `AgentSideConnection`. Note the swap: `process.stdout` → `input` (writable side of the ACP transport), `process.stdin` → `output` (readable side). The `ndJsonStream` helper from the SDK handles framing.

```typescript
const input = stream.Writable.toWeb(process.stdout);
const output = stream.Readable.toWeb(process.stdin);
const transport = ndJsonStream(input, output);
```

Sources: [packages/coding-agent/src/modes/acp/acp-mode.ts:16-23](packages/coding-agent/src/modes/acp/acp-mode.ts)

### Session lifecycle

`AcpAgent` (the `Agent` implementation handed to `AgentSideConnection`) owns a `Map<sessionId, ManagedSessionRecord>`. Each record holds the `AgentSession`, an optional `MCPManager`, a prompt-turn state object, and a prompt queue. Session IDs are generated by `SessionManager`.

The agent responds to the following ACP lifecycle requests:

| Request | Behaviour |
|---|---|
| `initialize` | Advertises capabilities; returns auth methods |
| `authenticate` | Validates `methodId` against advertised methods |
| `session/new` | Creates a fresh session via the factory, skips on-disk MCP discovery (`enableMCP: false`) |
| `session/load` | Opens an existing session file and replays message history |
| `session/resume` | Resumes the most-recent session for a given cwd |
| `unstable_session/fork` | Forks a session at a specific entry ID |
| `session/close` | Disposes the session record |
| `session/list` | Returns stored sessions for a cwd with pagination |

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:385-465](packages/coding-agent/src/modes/acp/acp-agent.ts)

### Why ACP sessions disable MCP discovery

When a client creates a session via `session/new`, it supplies `mcpServers` directly. The factory therefore sets `enableMCP: false` on every session to prevent on-disk `.mcp.json` discovery from registering duplicate or conflicting tools alongside the client-supplied servers.

Sources: [packages/coding-agent/src/main.ts:219-238](packages/coding-agent/src/main.ts)

### Host tool and permission routing via ClientBridge

ACP clients that advertise `fs.readTextFile`, `fs.writeTextFile`, or `terminal` capabilities at `initialize` time get a `ClientBridge` backed by `AgentSideConnection` calls (`connection.readTextFile`, `connection.writeTextFile`). This lets the editor's file system and terminal serve as the I/O backends for agent tool calls, replacing the agent's local filesystem access when the client opts in.

Sources: [packages/coding-agent/src/modes/acp/acp-client-bridge.ts:25-60](packages/coding-agent/src/modes/acp/acp-client-bridge.ts)

### Extension UI via ACP elicitations

`createAcpExtensionUiContext` bridges `ExtensionUIContext` calls to `connection.unstable_createElicitation`. Each `select`, `confirm`, or `input` call becomes a one-property form elicitation with a `value` field typed to `string` (enum-constrained for `select`), `boolean` (for `confirm`), or free `string` (for `input`). Clients that do not advertise `elicitation.form` get `undefined`/`false` defaults silently, rather than a runtime error.

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:291-363](packages/coding-agent/src/modes/acp/acp-agent.ts)

### Race-guard bootstrap delay

After a `session/new` (or load/resume/fork) response is sent, ACP emits initial `sessionNotification` events on a 50 ms delayed schedule (`ACP_BOOTSTRAP_RACE_GUARD_MS`). This mitigates a Zed client bug where notifications arrive before the client has registered the new session ID.

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:88-92](packages/coding-agent/src/modes/acp/acp-agent.ts)

---

## Comparison Table

| Dimension | Interactive | Print (`text`/`json`) | RPC / RPC-UI | ACP |
|---|---|---|---|---|
| CLI flag | _(none / default)_ | `--print`, `--mode text`, `--mode json` | `--mode rpc`, `--mode rpc-ui` | `--mode acp` |
| Session count | 1 | 1 | 1 | N (managed map) |
| I/O framing | Raw terminal (TUI) | Plain text / NDJSON | NDJSON (bidirectional) | NDJSON via ACP SDK |
| Stdout usage | TUI rendering | Agent output only | JSON protocol channel | ACP protocol channel |
| Stdin usage | Keyboard / TTY | Initial prompt | JSON commands | ACP commands |
| Process lifetime | Until user quits | Exits after last prompt | Until stdin closes | Until connection closes |
| Host tools | No | No | `set_host_tools` command | Via `session/new` mcpServers |
| Host URI schemes | No | No | `set_host_uri_schemes` | No |
| Extension UI | Full TUI dialogs | stderr errors only | `extension_ui_request` frames | ACP `unstable_createElicitation` |
| Terminal title | Yes | No | No (suppress via `PI_NO_TITLE`) | No |
| hasUI flag | `true` | `false` | `true` (rpc-ui only) | `false` |
| MCP discovery | On-disk `.mcp.json` | On-disk `.mcp.json` | On-disk `.mcp.json` | Client-supplied only |
| RPC default overrides | No | No | Yes | Yes |

Sources: [packages/coding-agent/src/main.ts:774-799](packages/coding-agent/src/main.ts), [packages/coding-agent/src/main.ts:904-975](packages/coding-agent/src/main.ts)

---

## Data Flow Diagram

```text
┌──────────────────────────────────────────────────────────────────┐
│                        CLI Entry (main.ts)                       │
│  parseArgs → mode flag / autoPrint / isInteractive detection     │
└──────────────────────┬───────────────────────────────────────────┘
                       │
          ┌────────────┼──────────────────────────────┐
          │            │                              │
          ▼            ▼                              ▼
  ┌──────────┐  ┌──────────────┐           ┌──────────────────┐
  │ ACP mode │  │ Single-session│           │  Interactive     │
  │          │  │  path        │           │  mode            │
  │factory → │  └──────┬───────┘           │  (TUI)           │
  │AcpAgent  │         │                   └──────────────────┘
  │(N sessions│        ├── mode=="rpc"/"rpc-ui"
  │over ACP  │         │    └→ runRpcMode
  │ SDK conn)│         │         stdin: NDJSON RpcCommands
  └──────────┘         │         stdout: RpcResponse + events
                       │         host_tool_call ↔ host_tool_result
                       │
                       └── isInteractive==false (text/json)
                                └→ runPrintMode
                                      text: final assistant text
                                      json: all events as NDJSON
```

---

## Failure Modes

**RPC stdout pollution:** Any code that writes directly to `process.stdout` without a newline (OSC sequences, BEL) corrupts the JSON stream. RPC mode guards this by setting `PI_NOTIFICATIONS=off` immediately on entry, but extensions or native modules that bypass this can silently break JSON parsing on the host side.

**ACP session notification race:** If the client processes `session/new` but has not yet registered the session before the first `sessionNotification` arrives, events are silently dropped. The 50 ms bootstrap guard mitigates this but does not eliminate it under heavy load.

**ACP MCP server shadowing:** If `enableMCP: false` is not set when creating an ACP session (e.g. in the `initialSession` fast-path), on-disk MCP tool registrations could conflict with client-supplied servers, causing duplicate tool names. The factory explicitly sets this flag for all dynamically created sessions.

**Print mode stderr/exit ordering:** In text mode, the exit-code-1 path after a stream error uses `process.stderr.once("drain", ...)` to avoid truncating the error line when the write is async. If neither path is taken, a final empty `process.stdout.write("")` flushes the stream before `session.dispose()`.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:172-178](packages/coding-agent/src/modes/rpc/rpc-mode.ts), [packages/coding-agent/src/modes/acp/acp-agent.ts:88-92](packages/coding-agent/src/modes/acp/acp-agent.ts), [packages/coding-agent/src/modes/print-mode.ts:80-118](packages/coding-agent/src/modes/print-mode.ts)

---

The four modes share one engine but own strictly different I/O boundaries: interactive holds the terminal, print drains output and exits, RPC keeps the channel open for bidirectional command/event framing, and ACP adds session multiplexing and a richer editor-integration contract on top of the same NDJSON transport. Understanding which mode owns stdout — and what else may write there — is the key invariant a maintainer needs to reason about when adding any new output path.

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

---

## 05. Tools, LSP & DAP — What the Agent Can Touch

> The 32 built-in tools (read, write, bash, search, eval, fetch, gh…), the 13 LSP operations wired through workspace/willRenameFiles, and the 27 DAP operations that attach real debuggers. State ownership, caching boundaries, and what differs between tool categories.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/05-tools-lsp-dap-what-the-agent-can-touch.md
- Generated: 2026-05-21T22:04:00.948Z

### Source Files

- `packages/coding-agent/src/tools/index.ts`
- `packages/coding-agent/src/tools/bash.ts`
- `packages/coding-agent/src/tools/read.ts`
- `packages/coding-agent/src/lsp/index.ts`
- `packages/coding-agent/src/lsp/client.ts`
- `packages/coding-agent/src/dap`
- `packages/coding-agent/src/tools/eval.ts`

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

- [packages/coding-agent/src/tools/index.ts](packages/coding-agent/src/tools/index.ts)
- [packages/coding-agent/src/tools/bash.ts](packages/coding-agent/src/tools/bash.ts)
- [packages/coding-agent/src/tools/read.ts](packages/coding-agent/src/tools/read.ts)
- [packages/coding-agent/src/tools/eval.ts](packages/coding-agent/src/tools/eval.ts)
- [packages/coding-agent/src/lsp/index.ts](packages/coding-agent/src/lsp/index.ts)
- [packages/coding-agent/src/lsp/client.ts](packages/coding-agent/src/lsp/client.ts)
- [packages/coding-agent/src/dap/session.ts](packages/coding-agent/src/dap/session.ts)
- [packages/coding-agent/src/dap/types.ts](packages/coding-agent/src/dap/types.ts)
</details>

# Tools, LSP & DAP — What the Agent Can Touch

The coding agent operates through three distinct categories of capability: **built-in tools** that compose its core action vocabulary, **LSP operations** that wire live language server intelligence into file editing and navigation, and **DAP operations** that attach real debuggers to running processes. Understanding what falls into each category, who owns the state, and what settings gate each surface is essential for reasoning about what the agent can and cannot do in any given session.

This page covers the full inventory of built-in tools registered in `BUILTIN_TOOLS` and `HIDDEN_TOOLS`, the LSP action set surfaced through the `lsp` tool (including the `workspace/willRenameFiles` integration), and the complete DAP session API that `DapSessionManager` exposes. It also explains caching, concurrency, and settings-gate invariants that differ between the three categories.

---

## Built-in Tools

### Registry and Load Modes

All built-in tools are declared as factory functions in two maps in `packages/coding-agent/src/tools/index.ts`:

- **`BUILTIN_TOOLS`** — the primary, settings-gated map of 33 tool factories that are materialized for each session.
- **`HIDDEN_TOOLS`** — five additional tools (`yield`, `report_finding`, `report_tool_issue`, `resolve`, `goal`) that are injected conditionally and are never directly selectable by name in the tool list.

Sources: [packages/coding-agent/src/tools/index.ts:283-322]()

Every tool carries a `loadMode`: either `"essential"` or `"discoverable"`. The default essential set is `["read", "bash", "edit"]`, configurable via `tools.essentialOverride`. Discoverable tools such as `lsp` (`loadMode = "discoverable"`) are hidden from the model until either selected explicitly or surfaced through `search_tool_bm25`.

Sources: [packages/coding-agent/src/tools/index.ts:260-277](), [packages/coding-agent/src/lsp/index.ts:1178-1179]()

### Full Tool Inventory

| Name | Class / Factory | Concurrency | Settings Gate |
|------|----------------|-------------|---------------|
| `read` | `ReadTool` | — | always on |
| `bash` | `BashTool` | `exclusive` | always on |
| `edit` | `EditTool` | — | always on |
| `ast_grep` | `AstGrepTool` | — | `astGrep.enabled` |
| `ast_edit` | `AstEditTool` | — | `astEdit.enabled` |
| `render_mermaid` | `RenderMermaidTool` | — | `renderMermaid.enabled` |
| `ask` | `AskTool.createIf` | — | conditional |
| `debug` | `DebugTool.createIf` | — | `debug.enabled` |
| `eval` | `EvalTool` | — | either `eval.py` or `eval.js` |
| `calc` | `CalculatorTool` | — | `calc.enabled` |
| `ssh` | `loadSshTool` | — | conditional |
| `github` | `GithubTool.createIf` | — | `github.enabled` |
| `find` | `FindTool` | — | `find.enabled` |
| `search` | `SearchTool` | — | `search.enabled` |
| `lsp` | `LspTool.createIf` | — | `lsp.enabled` + `enableLsp` |
| `inspect_image` | `InspectImageTool` | — | `inspect_image.enabled` |
| `browser` | `BrowserTool` | — | `browser.enabled` |
| `checkpoint` | `CheckpointTool.createIf` | — | `checkpoint.enabled` |
| `rewind` | `RewindTool.createIf` | — | `checkpoint.enabled` |
| `task` | `TaskTool.create` | — | depth < `task.maxRecursionDepth` |
| `job` | `JobTool.createIf` | — | conditional |
| `recipe` | `RecipeTool.createIf` | — | `recipe.enabled` |
| `irc` | `IrcTool.createIf` | — | `irc.enabled` + async mode |
| `todo_write` | `TodoWriteTool` | — | `todo.enabled`, disabled when `yield` present |
| `web_search` | `WebSearchTool` | — | `web_search.enabled` |
| `search_tool_bm25` | `SearchToolBm25Tool.createIf` | — | `tools.discoveryMode` ≠ `off` |
| `write` | `WriteTool` | — | always on |
| `retain` | `HindsightRetainTool.createIf` | — | `memory.backend === "hindsight"` |
| `recall` | `HindsightRecallTool.createIf` | — | `memory.backend === "hindsight"` |
| `reflect` | `HindsightReflectTool.createIf` | — | `memory.backend === "hindsight"` |
| `yield` *(hidden)* | `YieldTool` | — | `requireYieldTool` flag |
| `resolve` *(hidden)* | `ResolveTool` | — | always injected |
| `goal` *(hidden)* | `GoalTool` | — | `goal.enabled` + active goal mode |
| `report_finding` *(hidden)* | `reportFindingTool` | — | always |
| `report_tool_issue` *(hidden)* | `createReportToolIssueTool` | — | autoQA enabled |

### The `bash` Tool — Concurrency and State

`BashTool` declares `concurrency = "exclusive"`, which means no other tool invocation can run in parallel with bash. This is the only built-in tool with this constraint.

When `async.enabled` is true, the schema gains an `async` field. With `bash.autoBackground.enabled`, jobs that exceed `bash.autoBackground.thresholdMs` (default 60 s) are automatically backgrounded and tracked by `AsyncJobManager`. The tool resolves `skill://`, `agent://`, and `local://` internal URLs in command strings before spawning the process.

Sources: [packages/coding-agent/src/tools/bash.ts:225-257]()

### The `eval` Tool — Dual-Backend and Kernel State

`EvalTool` dispatches across two backends: `"py"` (an IPython/Jupyter kernel) and `"js"` (a persistent V8 VM). State within each language persists across cells and across successive tool calls in the same session. The `reset: true` flag on a cell wipes only that cell's language kernel. The tool is enabled if *either* backend is reachable; Python availability is checked at startup only when JS is disabled.

Sources: [packages/coding-agent/src/tools/eval.ts:34-51](), [packages/coding-agent/src/tools/index.ts:383-402]()

### The `read` Tool — File Read Cache

`ReadTool` writes every file it reads into a per-session `FileReadCache` stored on `ToolSession.fileReadCache`. This cache is consumed by the `edit` tool's hashline anchor-stale recovery path to reconstruct the file version the model authored its edit anchors against, in case the file changed out-of-band between `read` and `edit`.

Sources: [packages/coding-agent/src/tools/index.ts:239-250]()

---

## LSP Operations

### Architecture

The `lsp` tool is a single `AgentTool` with an `action` discriminant field. It connects lazily to language servers defined in the project's LSP config, reusing long-lived `LspClient` processes across tool calls. A module-level `Map<string, LspClient>` in `lsp/client.ts` acts as the client registry; locks prevent concurrent initialization of the same server. An idle-check interval (default 60 s cadence) shuts down clients that have exceeded the configured idle timeout.

Sources: [packages/coding-agent/src/lsp/client.ts:20-54]()

Config is cached per `cwd` in a `Map<string, LspConfig>` inside `lsp/index.ts` to avoid repeated file I/O on every tool call. The cache is populated on first use and reused for the session lifetime.

Sources: [packages/coding-agent/src/lsp/index.ts:226-236]()

### The 14 LSP Actions

| Action | LSP Method(s) | Scope | Notes |
|--------|--------------|-------|-------|
| `status` | — | workspace | Reports configured servers and lspmux state |
| `diagnostics` | `textDocument/publishDiagnostics`, linter clients | file or `*` | `*` runs workspace-level compile tool (cargo/tsc/go build/pyright) |
| `definition` | `textDocument/definition` | file + position | Normalizes `Location` and `LocationLink` |
| `type_definition` | `textDocument/typeDefinition` | file + position | Same normalization |
| `implementation` | `textDocument/implementation` | file + position | Same normalization |
| `references` | `textDocument/references` | file + position | Retries up to 2× with delay for project-aware servers |
| `hover` | `textDocument/hover` | file + position | Returns markdown or plaintext |
| `code_actions` | `textDocument/codeAction`, `codeAction/resolve`, `workspace/executeCommand` | file + position | `apply=true` applies the selected action |
| `symbols` | `textDocument/documentSymbol` / `workspace/symbol` | file or `*` + query | Workspace symbols deduped and limited to 200 |
| `rename` | `textDocument/rename` | file + position | `apply=false` returns a preview |
| `rename_file` | `workspace/willRenameFiles` + `workspace/didRenameFiles` | path pair | See below |
| `reload` | `rust-analyzer/reloadWorkspace` or `workspace/didChangeConfiguration`, then kill | server | Tries reload methods in order |
| `capabilities` | — | server | Returns raw `serverCapabilities` JSON |
| `request` | any method via `query` | file or server | Escape hatch for server-specific methods |

Sources: [packages/coding-agent/src/lsp/index.ts:1196-2281]()

### `rename_file` and `workspace/willRenameFiles`

This action is the most complex LSP operation. It:

1. Enumerates `{oldUri, newUri}` pairs (up to `MAX_RENAME_PAIRS = 1000`) by walking directory contents recursively if the source is a directory.
2. Sends `workspace/willRenameFiles` to every configured LSP server in parallel to collect `WorkspaceEdit` responses — the server returns the import-path updates it would make.
3. Coalesces per-URI text edits across servers: project-aware servers take priority; overlapping edits from lower-priority servers are discarded with a warning.
4. Applies the coalesced text edits atomically from a single pre-rename snapshot (not re-reading after each server), then performs the filesystem rename.
5. Sends `workspace/didRenameFiles` and `textDocument/didClose` notifications to all servers.

The `apply=false` flag returns a preview of what edits would be made without touching the filesystem.

Sources: [packages/coding-agent/src/lsp/index.ts:1380-1646]()

### LSP Writethrough: How `write`/`edit` Hook Into LSP

When `createLspWritethrough` is used (called by `WriteTool` and `EditTool`), every file write goes through a pipeline:

```text
write(content)
  → syncFileContent()      # didOpen or didChange to all LSP servers
  → formatContent()        # textDocument/formatting (in-memory)
  → write to disk
  → notifyFileSaved()      # didSave
  → waitForDiagnostics()   # poll up to 3s for fresh publishDiagnostics
```

If the entire pipeline exceeds 5 seconds, it times out, writes the file anyway, and optionally schedules a deferred diagnostics fetch (up to 25 s) that fires a callback when results arrive.

Sources: [packages/coding-agent/src/lsp/index.ts:984-1101]()

---

## DAP Operations

### Session Model

`DapSessionManager` maintains a `Map<string, DapSession>` where each session holds a live `DapClient` (a subprocess running a debug adapter), breakpoint state, output buffer, and a `DapSessionStatus` state machine. There is at most one *active* session at a time, selected by `#activeSessionId`. Sessions that exceed `IDLE_TIMEOUT_MS` (10 minutes) are garbage-collected by a cleanup loop running every 30 seconds.

Sources: [packages/coding-agent/src/dap/session.ts:66-100](), [packages/coding-agent/src/dap/session.ts:193-211]()

```text
DapSession state machine:

  launching → configuring → stopped ⇄ running
                                 ↓
                            terminated
```

The `configuring` state exists because DAP adapters often delay their `launch` response until after `configurationDone` is sent. The manager subscribes to `stopped` events *before* sending `launch` or `attach` to avoid missing `stopOnEntry` events.

Sources: [packages/coding-agent/src/dap/session.ts:238-279]()

### The 27 DAP Operations

#### Session Lifecycle
| Method | DAP Command(s) | Notes |
|--------|---------------|-------|
| `launch` | `initialize`, `launch`, `configurationDone` | 30 s default timeout; captures initial stop |
| `attach` | `initialize`, `attach`, `configurationDone` | Accepts `pid`, `port`, `host` |
| `terminate` | `terminate` + `disconnect` | Graceful then forced; cleans up session |

#### Breakpoints
| Method | DAP Command | State Owned |
|--------|------------|-------------|
| `setBreakpoint` | `setBreakpoints` | `session.breakpoints: Map<path, DapBreakpointRecord[]>` |
| `removeBreakpoint` | `setBreakpoints` (updated list) | same |
| `setFunctionBreakpoint` | `setFunctionBreakpoints` | `session.functionBreakpoints` |
| `removeFunctionBreakpoint` | `setFunctionBreakpoints` | same |
| `setInstructionBreakpoint` | `setInstructionBreakpoints` | `session.instructionBreakpoints` |
| `removeInstructionBreakpoint` | `setInstructionBreakpoints` | same |
| `dataBreakpointInfo` | `dataBreakpointInfo` | read-only query |
| `setDataBreakpoint` | `setDataBreakpoints` | `session.dataBreakpoints` |
| `removeDataBreakpoint` | `setDataBreakpoints` | same |

Breakpoint mutations always re-send the *complete* list for the affected file or category (not a delta), which is the correct DAP contract.

Sources: [packages/coding-agent/src/dap/session.ts:337-575]()

#### Execution Control
| Method | DAP Command | Notes |
|--------|------------|-------|
| `continue` | `continue` | Resets `session.stop` and `lastStackFrames` before sending |
| `pause` | `pause` | Waits for `stopped` event |
| `stepIn` | `stepIn` | |
| `stepOut` | `stepOut` | |
| `stepOver` | `next` | |

Sources: [packages/coding-agent/src/dap/session.ts:704-754]()

#### Inspection
| Method | DAP Command | Caches Result |
|--------|------------|--------------|
| `threads` | `threads` | Updates `session.threads` |
| `stackTrace` | `stackTrace` | Updates `session.lastStackFrames` and top-frame stop location |
| `scopes` | `scopes` | Falls back to `session.stop.frameId` if no `frameId` passed |
| `variables` | `variables` | No session cache |
| `evaluate` | `evaluate` | Defaults to top stopped frame |
| `getOutput` | — | Returns in-memory `session.output` buffer (up to 128 KB) |

Sources: [packages/coding-agent/src/dap/session.ts:756-863]()

#### Low-Level / Advanced
| Method | DAP Command | Notes |
|--------|------------|-------|
| `disassemble` | `disassemble` | Returns `DapDisassembledInstruction[]` |
| `readMemory` | `readMemory` | Returns base64 data |
| `writeMemory` | `writeMemory` | Accepts base64 data |
| `modules` | `modules` | Paginated via `startModule`/`moduleCount` |
| `loadedSources` | `loadedSources` | Returns all currently loaded source files |
| `customRequest` | any command | Escape hatch for adapter-specific methods |

Sources: [packages/coding-agent/src/dap/session.ts:577-702]()

### Output Buffer Ownership

Program stdout/stderr captured during debugging is appended to `session.output` (capped at `MAX_OUTPUT_BYTES = 128 KB`). When the buffer overflows, oldest bytes are sliced from the front and `session.outputTruncated` is set. `getOutput(limitBytes?)` retrieves a suffix of the buffer without clearing it — output is *not* consumed on read.

Sources: [packages/coding-agent/src/dap/session.ts:97-100](), [packages/coding-agent/src/dap/session.ts:147-155](), [packages/coding-agent/src/dap/session.ts:851-863]()

---

## State Ownership and Caching Boundaries

```text
┌─────────────────────────────────────────────────────────────┐
│ ToolSession (per agent session)                             │
│                                                             │
│  fileReadCache ← populated by ReadTool                      │
│  conflictHistory ← populated by ReadTool (conflict:// URIs) │
│  skills, settings, cwd                                      │
└───────────────┬────────────────────────────────────────────┘
                │ passed to all tool factories
        ┌───────┼────────────────────────┐
        ▼                ▼               ▼
 ┌──────────┐   ┌──────────────┐  ┌──────────────────┐
 │ Bash     │   │ LSP subsystem│  │ DAP subsystem    │
 │ (no      │   │              │  │                  │
 │ session  │   │ module-level │  │ DapSessionManager│
 │ state)   │   │ Map<cwd,     │  │ (singleton per   │
 │          │   │ LspClient>   │  │ process)         │
 └──────────┘   │ Map<cwd,     │  │ Map<id, DapSess> │
                │ LspConfig>   │  │ active session   │
                └──────────────┘  └──────────────────┘
```

- **Bash** is stateless between calls: no in-process state survives a tool call boundary (env vars set with `env:` do not persist to the next invocation unless the session itself carries them).
- **LSP** state (open files, diagnostics, server capabilities) lives in module-level singleton maps, shared across all sessions in the same process. File content synced via `syncContent` updates the server's in-memory view; the on-disk write happens separately.
- **DAP** state is owned by `DapSessionManager`, also a process-level singleton. Breakpoints, output, and stop location are maintained per session object and survive across multiple tool calls until the session is terminated or idle-collected.
- **Eval** kernel state (Python variables, JS globals) is per-session within the kernel subprocess and persists across eval tool calls until `reset: true` is passed or the session is torn down.

---

## What Differs Between Tool Categories

| Dimension | Built-in Tools | LSP Tool | DAP (via debug tool) |
|-----------|---------------|----------|---------------------|
| State lifetime | Mostly per-call (except `eval`, `read` cache) | Process-level (shared across sessions) | Session until idle timeout (10 min) |
| Settings gate | Per-tool booleans | `lsp.enabled` + `enableLsp` | `debug.enabled` |
| Concurrency | `bash` is `exclusive`; rest parallel | Parallel per file, locked per server init | Single active session |
| Caching | `fileReadCache` on `ToolSession` | `LspConfig` per `cwd`, `LspClient` per server | Output buffer in `DapSession` |
| Timeout | Per-tool (bash: 1–3600 s, lsp: clamped) | Per-operation, 5 s writethrough wall clock | Per-request (default 30 s) |
| Mutation scope | Filesystem | Filesystem + LSP server in-memory state | Debuggee process memory and execution |

The `resolve` hidden tool is always injected regardless of the requested tool list, functioning as the control surface for plan-mode and queue-directed invocations. It is the only tool whose presence is enforced unconditionally.

Sources: [packages/coding-agent/src/tools/index.ts:505-511]()

---

## 06. 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.

- Page Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/pages/06-extension-model-providers-plugins-skills-safe-change-rules.md
- Generated: 2026-05-21T21:58:51.214Z

### 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]()

---