From 0b102787cd62f8cadc7f2cee5ddf88d76f388e40 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sun, 18 Oct 2026 00:52:04 +0100 Subject: [PATCH] docs(coding-agent): restore extension guide details (#11039) --- packages/coding-agent/docs/custom-provider.md | 54 +++++++- packages/coding-agent/docs/extensions.md | 124 ++++++++++++++++-- packages/coding-agent/docs/tui.md | 70 ++++++++++ 3 files changed, 238 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 646dea39e..248d6d0cd 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -29,7 +29,59 @@ Prefer a complete provider for new integrations that own more than static endpoi Registering only `baseUrl` or `headers` for an existing provider preserves its built-in models. Supplying `models` in the legacy form replaces that provider's models across chat, image, and classifier operations. An omitted `type` means `"chat"`; image and classifier models require explicit discriminants and implementations keyed by their `api` values through the `images` and `classifiers` fields. -For example, a mixed-operation provider can register non-chat models and their implementations together: +### Redirect an existing provider + +Register only the fields that should change. This keeps the provider's built-in catalog, authentication, API implementation, and compatibility settings: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerProvider("anthropic", { + baseUrl: "https://gateway.example.com/anthropic", + headers: { + "X-Gateway-Key": "$GATEWAY_API_KEY", + }, + }); +} +``` + +Use this form for a proxy or gateway that preserves the upstream protocol. Do not copy the built-in model list into the extension because doing so turns a small endpoint override into a catalog replacement that can drift. + +### Register a compatible endpoint + +When a service implements a supported protocol but has its own model catalog, register the endpoint and only the models the service actually exposes: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerProvider("acme", { + name: "Acme AI", + baseUrl: "https://api.acme.example/v1", + apiKey: "$ACME_API_KEY", + api: "openai-completions", + models: [ + { + type: "chat", + id: "acme-chat", + name: "Acme Chat", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }); +} +``` + +Prefer `models.json` for this case when no executable extension behavior is needed. Use an extension when registration depends on runtime discovery, custom authentication, or code shared with other extension features. + +### Register mixed-operation models + +A mixed-operation provider can register non-chat models and their implementations together: ```typescript pi.registerProvider("media-tools", { diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index c0c7fd23c..c3836c81b 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -51,7 +51,7 @@ Reload replaces the extension runtime, so code after `await ctx.reload()` must n -## Respect the runtime lifecycle +## Understand the runtime lifecycle The factory can be synchronous or asynchronous. Pi waits for an asynchronous factory before startup continues, allowing it to fetch configuration or register providers needed during startup. @@ -59,13 +59,95 @@ Do not start processes, sockets, watchers, or timers in the factory because some Start long-lived resources from `session_start` or from the command or tool that needs them. Close session-scoped resources from an idempotent `session_shutdown` handler. -A run proceeds from input and `before_agent_start`, through model, message, and tool events, to `agent_end`. -Pi counts as busy while a run starts, including the compaction check and `before_agent_start`, so messages sent meanwhile go into the starting run. -Automatic retries, recovery, compaction, or queued work can continue afterward. +A normal prompt follows this hook sequence: + +```mermaid +flowchart TD + Input["input"] --> Before["before_agent_start"] + Before --> AgentStart["agent_start"] + AgentStart --> TurnStart["turn_start"] + TurnStart --> Context["context"] + Context --> ContextSystem["context_with_system"] + ContextSystem --> Headers["before_provider_headers"] + Headers --> Request["before_provider_request"] + Request --> Response["after_provider_response"] + Response --> Stream["provider_stream_event and assistant message events"] + Stream --> ToolDecision{"Assistant requested tools?"} + ToolDecision -->|yes| ToolStart["tool_execution_start"] + ToolStart --> ToolCall["tool_call"] + ToolCall --> Execute["execute and tool_execution_update"] + Execute --> ToolResult["tool_result"] + ToolResult --> ToolEnd["tool_execution_end"] + ToolEnd --> TurnEnd["turn_end"] + ToolDecision -->|no| TurnEnd + TurnEnd --> MoreTurns{"Another model turn required?"} + MoreTurns -->|yes| TurnStart + MoreTurns -->|no| AgentEnd["agent_end"] + AgentEnd --> MoreRuns{"Retry, recovery, compaction retry, or queued work?"} + MoreRuns -->|yes| AgentStart + MoreRuns -->|no| BeforeSettle["agent_before_settle"] + BeforeSettle --> Continue{"Continuation requested?"} + Continue -->|yes| AgentStart + Continue -->|no| Settled["agent_settled"] +``` + +The lifecycle involves these event groups: + +| Area | Events | +|---|---| +| Input preparation | `input`, `before_agent_start` | +| Agent run | `agent_start`, `agent_end` | +| Model turn | `turn_start`, `context`, `context_with_system`, `turn_end` | +| Provider call | `before_provider_headers`, `before_provider_request`, `after_provider_response`, `provider_stream_event` | +| Message lifecycle | `message_start`, `message_update`, `message_end` | +| Tool execution | `tool_execution_start`, `tool_call`, `tool_execution_update`, `tool_result`, `tool_execution_end` | +| Settlement | `agent_before_settle`, `agent_settled` | + +Pi counts as busy while a run starts, including the compaction check and `before_agent_start`, so messages sent meanwhile go into the starting run. Message lifecycle events also cover user and tool-result messages; the diagram places only the assistant stream where it occurs in each model turn. + +Tool hooks run for each tool call, and one assistant response can start another turn by returning tool calls. Provider hooks run only when that transport exposes the corresponding stage. See the exported declarations in [`extensions/types.ts`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts) for exact event payloads and result types. + +Session startup and replacement follow a separate lifecycle: + +```mermaid +flowchart TD + Startup["Process startup"] --> Trust["project_trust when needed"] + Trust --> SessionStart["session_start"] + Switch["/new or /resume"] --> BeforeSwitch["session_before_switch"] + Fork["/fork or /clone"] --> BeforeFork["session_before_fork"] + BeforeSwitch --> Shutdown["session_shutdown"] + BeforeFork --> Shutdown + Reload["reload"] --> Shutdown + Shutdown --> Replacing{"Replacing the runtime?"} + Replacing -->|yes| Replacement["load replacement extension runtime"] + Replacement --> SessionStart + Replacing -->|no| Stop["process exits"] + SessionStart --> Discover["resources_discover"] +``` + +`project_trust` runs before trusted project resources load and can run again when session replacement enters a new working directory. Retries, recovery, compaction, and queued messages can start another agent run after `agent_end`, so `agent_end` is not a final-idle signal. Reload and session replacement invalidate the old runtime. Code after `await ctx.reload()` still belongs to the old runtime and must not reuse its session-bound state. + +### Choose an event by intent + +| Goal | Event | +|---|---| +| Inspect, transform, or consume raw user input | `input` | +| Change prompt sections, active tools, or add context before a run | `before_agent_start` | +| Observe one low-level agent run | `agent_start`, `agent_end` | +| Act after one model response and its tool calls | `turn_end` | +| Filter conversation messages for one provider request | `context` | +| Transform the complete transcript, including system messages | `context_with_system` | +| Change request headers or the serialized provider payload | `before_provider_headers`, `before_provider_request` | +| Observe provider response metadata or parsed stream events | `after_provider_response`, `provider_stream_event` | +| Authorize or modify a tool call | `tool_call` | +| Redact, annotate, or replace a tool result | `tool_result` | +| Append final entries or request one last continuation | `agent_before_settle` | +| Run notification work after automatic processing has stopped | `agent_settled` | +| Initialize or release session-scoped resources | `session_start`, `session_shutdown` | + -`agent_before_settle` is the final actionable boundary: it can append entries and request one continuation. -`agent_settled` is final and notification-only; use it when an integration needs to know Pi will not continue automatically. +Use `agent_before_settle` when an extension must append entries or request one final continuation. Use `agent_settled` for notification-only work that should run after Pi has no automatic continuation remaining. @@ -101,6 +183,19 @@ Use each event’s declared result type rather than assuming every return value Events cover resource discovery, sessions, agent and message lifecycle, providers, tools, and raw input. +#### Understand handler composition + +| Pattern | Behavior | +|---|---| +| Notification | Return values are ignored. Use the event for observation only. | +| In-place mutation | Later handlers see changes to values such as `before_provider_headers.headers`, `before_agent_start.systemPromptOptions`, and `tool_call.input`. | +| Chained transformation | Later handlers receive the value returned by earlier `input`, `context`, `context_with_system`, `message_end`, `tool_result`, and `before_provider_request` handlers. | +| Gate | A handled input, blocked tool call, cancelled session operation, or handled `user_bash` command stops that operation's normal fallback path. | +| Actionable boundary | Each `turn_end` or `agent_before_settle` handler sees the entries and continuation decision proposed so far. | +| Last override | The last `cache_warming_decision` handler that returns an action wins. | + +Notification handlers should not perform slow work on a streaming event. Transform handlers should preserve fields they do not intentionally own, and gate handlers should fail closed when their safety decision cannot be made. + `before_agent_start` exposes both the current prompt and its structured `systemPromptOptions`. `event.messageType` says what started the run: `"user"` for a prompt or `pi.sendUserMessage()`, with `prompt` and `images`; `"custom"` for `pi.sendMessage(..., { triggerTurn: true })`, with the starting `message` and no `prompt`. A returned `message` is added to either kind of run; check `messageType` to skip runs started by custom messages. Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning `systemPrompt`, or setting `forceSystemPrompt`, replaces the whole prompt for that run while the transcript continues recording the structured sections. Providers receive the forced text as their leading system prompt. `message_end` can replace a finalized message while preserving its role. `tool_call` can mutate input or block execution. `tool_result` handlers compose, with each handler seeing prior changes. @@ -271,7 +366,18 @@ Use `ctx.shutdown()` to request an orderly process shutdown. ## Examples and reference -The checked [extension examples](../examples/extensions/) cover tools, lifecycle events, commands, flags, shortcuts, state, rendering, providers, OAuth, remote execution, and terminal components. -Start with the smallest example matching your integration point. +The checked [extension examples](../examples/extensions/) are the implementation reference. Start with the smallest example matching the behavior you need: -Use [Custom Providers](custom-provider.md) for model-service integrations, [Terminal UI](tui.md) for custom components, and [Pi Packages](packages.md) to install or distribute extensions with other resources. +| Goal | Start with | Why | +|---|---|---| +| Confirm or block tool calls | [`permission-gate.ts`](../examples/extensions/permission-gate.ts) | Combines `tool_call`, mode-aware UI, and fail-closed behavior. | +| Modify prompt sections for a run | [`prompt-customizer.ts`](../examples/extensions/prompt-customizer.ts) | Uses structured prompt options instead of replacing the complete prompt. | +| Keep branch-aware tool state | [`todo.ts`](../examples/extensions/todo.ts) | Reconstructs state from persisted entries and renders it in the TUI. | +| Register tools after startup | [`dynamic-tools.ts`](../examples/extensions/dynamic-tools.ts) | Shares registration logic between lifecycle and command handlers. | +| Run cancellable model work from a command | [`qna.ts`](../examples/extensions/qna.ts) | Combines nested model calls, cancellation, and custom UI. | +| Replace the main editor | [`modal-editor.ts`](../examples/extensions/modal-editor.ts) | Extends `CustomEditor` while preserving application controls. | +| Run finalization at shutdown | [`auto-commit-on-exit.ts`](../examples/extensions/auto-commit-on-exit.ts) | Uses the session shutdown hook for finalization work. | +| Route built-in tools to another machine | [`ssh.ts`](../examples/extensions/ssh.ts) | Reuses tool operation interfaces instead of recreating tool contracts. | +| Add OAuth and provider-specific streaming | [`custom-provider-gitlab-duo/`](../examples/extensions/custom-provider-gitlab-duo/) | Shows a complete provider integration while delegating supported protocols. | + +Use [Custom Providers](custom-provider.md) for model-service integrations, [Terminal UI](tui.md) for custom components, and [Pi Packages](packages.md) to install or distribute extensions with other resources. Use the exported declarations for exact signatures, payloads, and result schemas; this guide focuses on lifecycle choices, composition, and durable implementation patterns. diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 693e3a4e7..704a78633 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -65,6 +65,76 @@ Regular mode leaves mouse input to the terminal because the terminal owns scroll `ctx.ui.custom()` temporarily gives one component control of the interactive area and resolves when that component calls the supplied completion callback. +### Build a minimal custom screen + +This complete extension shows the custom-component lifecycle: render within the supplied width, use injected keybindings, invalidate cached output after state changes, request a render, and finish through `done()`. + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth } from "@earendil-works/pi-tui"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("pick-target", { + description: "Pick a deployment target", + handler: async (_args, ctx) => { + if (ctx.mode !== "tui") { + ctx.ui.notify("pick-target requires interactive mode", "error"); + return; + } + + const choices = ["development", "staging", "production"]; + const selected = await ctx.ui.custom((tui, theme, keybindings, done) => { + let selectedIndex = 0; + let cachedWidth: number | undefined; + let cachedLines: string[] | undefined; + + const invalidate = () => { + cachedWidth = undefined; + cachedLines = undefined; + }; + + return { + render(width: number): string[] { + if (cachedLines && cachedWidth === width) return cachedLines; + + cachedWidth = width; + cachedLines = [ + theme.fg("accent", "Choose a deployment target"), + ...choices.map((choice, index) => `${index === selectedIndex ? "> " : " "}${choice}`), + theme.fg("dim", "up/down move, enter selects, escape cancels"), + ].map((line) => truncateToWidth(line, width)); + return cachedLines; + }, + handleInput(data: string): void { + if (keybindings.matches(data, "tui.select.up")) { + selectedIndex = (selectedIndex - 1 + choices.length) % choices.length; + } else if (keybindings.matches(data, "tui.select.down")) { + selectedIndex = (selectedIndex + 1) % choices.length; + } else if (keybindings.matches(data, "tui.select.confirm")) { + done(choices[selectedIndex]); + return; + } else if (keybindings.matches(data, "tui.select.cancel")) { + done(undefined); + return; + } else { + return; + } + + invalidate(); + tui.requestRender(); + }, + invalidate, + }; + }); + + if (selected) ctx.ui.notify(`Selected ${selected}`, "info"); + }, + }); +} +``` + +This component does not display a text cursor, so it does not need `Focusable`. A component containing `Input` or `Editor` must implement `Focusable` and propagate its `focused` value to that child. + Pass `overlay: true` to draw above existing content. Overlay options control size, anchors, offsets, margins, and responsive visibility. An overlay handle can change focus or temporarily hide and show the overlay with `setHidden()` while the interaction remains active. Focused overlays retain input ownership across ordinary renders. If another component should receive input while an overlay remains visible, explicitly release or redirect focus through the handle.