docs(docs): moved documentation to root docs directory and updated all references
- Moved documentation files from packages/coding-agent/docs/ to root docs/ directory to flatten the documentation structure. - Updated all internal documentation links to account for the new file locations, adjusting relative paths to maintain correct references across the monorepo. - Updated README.md and issue template configuration to reference documentation at the new root docs/ location instead of packages/coding-agent/docs/.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
# Extensions
|
||||
|
||||
Primary guide for authoring runtime extensions in `packages/coding-agent`.
|
||||
|
||||
This document covers the current extension runtime in:
|
||||
|
||||
- `src/extensibility/extensions/types.ts`
|
||||
- `src/extensibility/extensions/runner.ts`
|
||||
- `src/extensibility/extensions/wrapper.ts`
|
||||
- `src/extensibility/extensions/index.ts`
|
||||
- `src/modes/controllers/extension-ui-controller.ts`
|
||||
|
||||
For discovery paths and filesystem loading rules, see `docs/extension-loading.md`.
|
||||
|
||||
## What an extension is
|
||||
|
||||
An extension is a TS/JS module exporting a default factory:
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
export default function myExtension(pi: ExtensionAPI) {
|
||||
// register handlers/tools/commands/renderers
|
||||
}
|
||||
```
|
||||
|
||||
Extensions can combine all of the following in one module:
|
||||
|
||||
- event handlers (`pi.on(...)`)
|
||||
- LLM-callable tools (`pi.registerTool(...)`)
|
||||
- slash commands (`pi.registerCommand(...)`)
|
||||
- keyboard shortcuts and flags
|
||||
- custom message rendering
|
||||
- session/message injection APIs (`sendMessage`, `sendUserMessage`, `appendEntry`)
|
||||
|
||||
## Runtime model
|
||||
|
||||
1. Extensions are imported and their factory functions run.
|
||||
2. During that load phase, registration methods are valid; runtime action methods are not yet initialized.
|
||||
3. `ExtensionRunner.initialize(...)` wires live actions/contexts for the active mode.
|
||||
4. Session/agent/tool lifecycle events are emitted to handlers.
|
||||
5. Every tool execution is wrapped with extension interception (`tool_call` / `tool_result`).
|
||||
|
||||
```text
|
||||
Extension lifecycle (simplified)
|
||||
|
||||
load paths
|
||||
│
|
||||
▼
|
||||
import module + run factory (registration only)
|
||||
│
|
||||
▼
|
||||
ExtensionRunner.initialize(mode/session/tool registry)
|
||||
│
|
||||
├─ emit session/agent events to handlers
|
||||
├─ wrap tool execution (tool_call/tool_result)
|
||||
└─ expose runtime actions (sendMessage, setActiveTools, ...)
|
||||
```
|
||||
|
||||
Important constraint from `loader.ts`:
|
||||
|
||||
- calling action methods like `pi.sendMessage()` during extension load throws `ExtensionRuntimeNotInitializedError`
|
||||
- register first; perform runtime behavior from events/commands/tools
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.setLabel("Safety + Utilities");
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info");
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Blocked by extension policy" };
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "hello_extension",
|
||||
label: "Hello Extension",
|
||||
description: "Return a greeting",
|
||||
parameters: Type.Object({ name: Type.String() }),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}` }],
|
||||
details: { greeted: params.name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("hello-ext", {
|
||||
description: "Show queue state",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Extension API surfaces
|
||||
|
||||
## 1) Registration and actions (`ExtensionAPI`)
|
||||
|
||||
Core methods:
|
||||
|
||||
- `on(event, handler)`
|
||||
- `registerTool`, `registerCommand`, `registerShortcut`, `registerFlag`
|
||||
- `registerMessageRenderer`
|
||||
- `sendMessage`, `sendUserMessage`, `appendEntry`
|
||||
- `getActiveTools`, `getAllTools`, `setActiveTools`
|
||||
- `setModel`, `getThinkingLevel`, `setThinkingLevel`
|
||||
- `registerProvider`
|
||||
- `events` (shared event bus)
|
||||
|
||||
Also exposed:
|
||||
|
||||
- `pi.logger`
|
||||
- `pi.typebox`
|
||||
- `pi.pi` (package exports)
|
||||
|
||||
### Message delivery semantics
|
||||
|
||||
`pi.sendMessage(message, options)` supports:
|
||||
|
||||
- `deliverAs: "steer"` (default) — interrupts current run
|
||||
- `deliverAs: "followUp"` — queued to run after current run
|
||||
- `deliverAs: "nextTurn"` — stored and injected on the next user prompt
|
||||
- `triggerTurn: true` — starts a turn when idle (`nextTurn` ignores this)
|
||||
|
||||
`pi.sendUserMessage(content, { deliverAs })` always goes through prompt flow; while streaming it queues as steer/follow-up.
|
||||
|
||||
## 2) Handler context (`ExtensionContext`)
|
||||
|
||||
Handlers and tool `execute` receive `ctx` with:
|
||||
|
||||
- `ui`
|
||||
- `hasUI`
|
||||
- `cwd`
|
||||
- `sessionManager` (read-only)
|
||||
- `modelRegistry`, `model`
|
||||
- `getContextUsage()`
|
||||
- `compact(...)`
|
||||
- `isIdle()`, `hasPendingMessages()`, `abort()`
|
||||
- `shutdown()`
|
||||
- `getSystemPrompt()`
|
||||
|
||||
## 3) Command context (`ExtensionCommandContext`)
|
||||
|
||||
Command handlers additionally get:
|
||||
|
||||
- `waitForIdle()`
|
||||
- `newSession(...)`
|
||||
- `switchSession(...)`
|
||||
- `branch(entryId)`
|
||||
- `navigateTree(targetId, { summarize })`
|
||||
- `reload()`
|
||||
|
||||
Use command context for session-control flows; these methods are intentionally separated from general event handlers.
|
||||
|
||||
## Event surface (current names and behavior)
|
||||
|
||||
Canonical event unions and payload types are in `types.ts`.
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- `session_start`
|
||||
- `session_before_switch` / `session_switch`
|
||||
- `session_before_branch` / `session_branch`
|
||||
- `session_before_compact` / `session.compacting` / `session_compact`
|
||||
- `session_before_tree` / `session_tree`
|
||||
- `session_shutdown`
|
||||
|
||||
Cancelable pre-events:
|
||||
|
||||
- `session_before_switch` → `{ cancel?: boolean }`
|
||||
- `session_before_branch` → `{ cancel?: boolean; skipConversationRestore?: boolean }`
|
||||
- `session_before_compact` → `{ cancel?: boolean; compaction?: CompactionResult }`
|
||||
- `session_before_tree` → `{ cancel?: boolean; summary?: { summary: string; details?: unknown } }`
|
||||
|
||||
### Prompt and turn lifecycle
|
||||
|
||||
- `input`
|
||||
- `before_agent_start`
|
||||
- `context`
|
||||
- `agent_start` / `agent_end`
|
||||
- `turn_start` / `turn_end`
|
||||
- `message_start` / `message_update` / `message_end`
|
||||
|
||||
### Tool lifecycle
|
||||
|
||||
- `tool_call` (pre-exec, may block)
|
||||
- `tool_result` (post-exec, may patch content/details/isError)
|
||||
- `tool_execution_start` / `tool_execution_update` / `tool_execution_end` (observability)
|
||||
|
||||
`tool_result` is middleware-style: handlers run in extension order and each sees prior modifications.
|
||||
|
||||
### Reliability/runtime signals
|
||||
|
||||
- `auto_compaction_start` / `auto_compaction_end`
|
||||
- `auto_retry_start` / `auto_retry_end`
|
||||
- `ttsr_triggered`
|
||||
- `todo_reminder`
|
||||
|
||||
### User command interception
|
||||
|
||||
- `user_bash` (override with `{ result }`)
|
||||
- `user_python` (override with `{ result }`)
|
||||
|
||||
### `resources_discover`
|
||||
|
||||
`resources_discover` exists in extension types and `ExtensionRunner`.
|
||||
Current runtime note: `ExtensionRunner.emitResourcesDiscover(...)` is implemented, but there are no `AgentSession` callsites invoking it in the current codebase.
|
||||
|
||||
## Tool authoring details
|
||||
|
||||
`registerTool` uses `ToolDefinition` from `types.ts`.
|
||||
|
||||
Current `execute` signature:
|
||||
|
||||
```ts
|
||||
execute(
|
||||
toolCallId,
|
||||
params,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx,
|
||||
): Promise<AgentToolResult>
|
||||
```
|
||||
|
||||
Template:
|
||||
|
||||
```ts
|
||||
pi.registerTool({
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "...",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_id, _params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
|
||||
return { content: [{ type: "text", text: "Done" }], details: {} };
|
||||
},
|
||||
onSession(event, ctx) {
|
||||
// reason: start|switch|branch|tree|shutdown
|
||||
},
|
||||
renderCall(args, theme) {
|
||||
// optional TUI render
|
||||
},
|
||||
renderResult(result, options, theme, args) {
|
||||
// optional TUI render
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`tool_call`/`tool_result` intercept all tools once the registry is wrapped in `sdk.ts`, including built-ins and extension/custom tools.
|
||||
|
||||
## UI integration points
|
||||
|
||||
`ctx.ui` implements the `ExtensionUIContext` interface. Support differs by mode.
|
||||
|
||||
### Interactive mode (`extension-ui-controller.ts`)
|
||||
|
||||
Supported:
|
||||
|
||||
- dialogs: `select`, `confirm`, `input`, `editor`
|
||||
- notifications/status/editor text/terminal input/custom overlays
|
||||
- theme listing/loading by name (`setTheme` supports string names)
|
||||
- tools expanded toggle
|
||||
|
||||
Current no-op methods in this controller:
|
||||
|
||||
- `setFooter`
|
||||
- `setHeader`
|
||||
- `setEditorComponent`
|
||||
|
||||
Also note: `setWidget` currently routes to status-line text via `setHookWidget(...)`.
|
||||
|
||||
### RPC mode (`rpc-mode.ts`)
|
||||
|
||||
`ctx.ui` is backed by RPC `extension_ui_request` events:
|
||||
|
||||
- dialog methods (`select`, `confirm`, `input`, `editor`) round-trip to client responses
|
||||
- fire-and-forget methods emit requests (`notify`, `setStatus`, `setWidget` for string arrays, `setTitle`, `setEditorText`)
|
||||
|
||||
Unsupported/no-op in RPC implementation:
|
||||
|
||||
- `onTerminalInput`
|
||||
- `custom`
|
||||
- `setFooter`, `setHeader`, `setEditorComponent`
|
||||
- `setWorkingMessage`
|
||||
- theme switching/loading (`setTheme` returns failure)
|
||||
- tool expansion controls are inert
|
||||
|
||||
### Print/headless/subagent paths
|
||||
|
||||
When no UI context is supplied to runner init, `ctx.hasUI` is `false` and methods are no-op/default-returning.
|
||||
|
||||
### Background interactive mode
|
||||
|
||||
Background mode installs a non-interactive UI context object. In current implementation, `ctx.hasUI` may still be `true` while interactive dialogs return defaults/no-op behavior.
|
||||
|
||||
## Session and state patterns
|
||||
|
||||
For durable extension state:
|
||||
|
||||
1. Persist with `pi.appendEntry(customType, data)`.
|
||||
2. Rebuild state from `ctx.sessionManager.getBranch()` on `session_start`, `session_branch`, `session_tree`.
|
||||
3. Keep tool result `details` structured when state should be visible/reconstructible from tool result history.
|
||||
|
||||
Example reconstruction pattern:
|
||||
|
||||
```ts
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
let latest;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "custom" && entry.customType === "my-state") {
|
||||
latest = entry.data;
|
||||
}
|
||||
}
|
||||
// restore from latest
|
||||
});
|
||||
```
|
||||
|
||||
## Rendering extension points
|
||||
|
||||
## Custom message renderer
|
||||
|
||||
```ts
|
||||
pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
|
||||
// return pi-tui Component
|
||||
});
|
||||
```
|
||||
|
||||
Used by interactive rendering when custom messages are displayed.
|
||||
|
||||
## Tool call/result renderer
|
||||
|
||||
Provide `renderCall` / `renderResult` on `registerTool` definitions for custom tool visualization in TUI.
|
||||
|
||||
## Constraints and pitfalls
|
||||
|
||||
- Runtime actions are unavailable during extension load.
|
||||
- `tool_call` errors block execution (fail-closed).
|
||||
- Command name conflicts with built-ins are skipped with diagnostics.
|
||||
- Reserved shortcuts are ignored (`ctrl+c`, `ctrl+d`, `ctrl+z`, `ctrl+k`, `ctrl+p`, `ctrl+l`, `ctrl+o`, `ctrl+t`, `ctrl+g`, `shift+tab`, `shift+ctrl+p`, `alt+enter`, `escape`, `enter`).
|
||||
- Treat `ctx.reload()` as terminal for the current command handler frame.
|
||||
|
||||
## Extensions vs hooks vs custom-tools
|
||||
|
||||
Use the right surface:
|
||||
|
||||
- **Extensions** (`src/extensibility/extensions/*`): unified system (events + tools + commands + renderers + provider registration).
|
||||
- **Hooks** (`src/extensibility/hooks/*`): separate legacy event API.
|
||||
- **Custom-tools** (`src/extensibility/custom-tools/*`): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers.
|
||||
|
||||
If you need one package that owns policy, tools, command UX, and rendering together, use extensions.
|
||||
Reference in New Issue
Block a user