diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index b7ea7f7c..ecdb2057 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -32,7 +32,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * @example * ```typescript * convertToLlm: (messages) => messages.flatMap(m => { - * if (m.role === "hookMessage") { + * if (m.role === "custom") { * // Convert custom message to user message * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; * } diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index c1ee890c..21290f16 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -474,31 +474,31 @@ describe("agentLoopContinue with AgentMessage", () => { it("should allow custom message types as last message (caller responsibility)", async () => { // Custom message that will be converted to user message by convertToLlm - interface HookMessage { - role: "hookMessage"; + interface CustomMessage { + role: "custom"; text: string; timestamp: number; } - const hookMessage: HookMessage = { - role: "hookMessage", + const customMessage: CustomMessage = { + role: "custom", text: "Hook content", timestamp: Date.now(), }; const context: AgentContext = { systemPrompt: "You are helpful.", - messages: [hookMessage as unknown as AgentMessage], + messages: [customMessage as unknown as AgentMessage], tools: [], }; const config: AgentLoopConfig = { model: createModel(), convertToLlm: (messages) => { - // Convert hookMessage to user message + // Convert custom to user message return messages .map((m) => { - if ((m as any).role === "hookMessage") { + if ((m as any).role === "custom") { return { role: "user" as const, content: (m as any).text, @@ -514,13 +514,13 @@ describe("agentLoopContinue with AgentMessage", () => { const streamFn = () => { const stream = new MockAssistantStream(); queueMicrotask(() => { - const message = createAssistantMessage([{ type: "text", text: "Response to hook" }]); + const message = createAssistantMessage([{ type: "text", text: "Response to custom message" }]); stream.push({ type: "done", reason: "stop", message }); }); return stream; }; - // Should not throw - the hookMessage will be converted to user message + // Should not throw - the custom message will be converted to user message const stream = agentLoopContinue(context, config, undefined, streamFn); const events: AgentEvent[] = []; diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 7c751d7f..7cf57237 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -424,13 +424,15 @@ describe("Context overflow error handling", () => { // Ollama (local) // ============================================================================= - // Check if ollama is installed + // Check if ollama is installed and local LLM tests are enabled let ollamaInstalled = false; - try { - execSync("which ollama", { stdio: "ignore" }); - ollamaInstalled = true; - } catch { - ollamaInstalled = false; + if (!process.env.PI_NO_LOCAL_LLM) { + try { + execSync("which ollama", { stdio: "ignore" }); + ollamaInstalled = true; + } catch { + ollamaInstalled = false; + } } describe.skipIf(!ollamaInstalled)("Ollama (local)", () => { @@ -514,15 +516,17 @@ describe("Context overflow error handling", () => { }); // ============================================================================= - // LM Studio (local) - Skip if not running + // LM Studio (local) - Skip if not running or local LLM tests disabled // ============================================================================= let lmStudioRunning = false; - try { - execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" }); - lmStudioRunning = true; - } catch { - lmStudioRunning = false; + if (!process.env.PI_NO_LOCAL_LLM) { + try { + execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" }); + lmStudioRunning = true; + } catch { + lmStudioRunning = false; + } } describe.skipIf(!lmStudioRunning)("LM Studio (local)", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 992132b3..334ddc07 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -878,13 +878,15 @@ describe("Generate E2E Tests", () => { }); }); - // Check if ollama is installed + // Check if ollama is installed and local LLM tests are enabled let ollamaInstalled = false; - try { - execSync("which ollama", { stdio: "ignore" }); - ollamaInstalled = true; - } catch { - ollamaInstalled = false; + if (!process.env.PI_NO_LOCAL_LLM) { + try { + execSync("which ollama", { stdio: "ignore" }); + ollamaInstalled = true; + } catch { + ollamaInstalled = false; + } } describe.skipIf(!ollamaInstalled)("Ollama Provider (gpt-oss-20b via OpenAI Completions)", () => { diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9d48f9df..a5c7f86c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -36,12 +36,11 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Custom System Prompt](#custom-system-prompt) - [Custom Models and Providers](#custom-models-and-providers) - [Settings File](#settings-file) -- [Extensions](#extensions) +- [Customization](#customization) - [Themes](#themes) - - [Custom Slash Commands](#custom-slash-commands) + - [Prompt Templates](#prompt-templates) - [Skills](#skills) - - [Hooks](#hooks) - - [Custom Tools](#custom-tools) + - [Extensions](#extensions) - [CLI Reference](#cli-reference) - [Tools](#tools) - [Programmatic Usage](#programmatic-usage) @@ -453,7 +452,7 @@ When disabled, neither case triggers automatic compaction (use `/compact` manual > **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point. -See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks. +See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via extensions. ### Branching @@ -667,8 +666,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: "images": { "autoResize": true }, - "hooks": ["/path/to/hook.ts"], - "customTools": ["/path/to/tool.ts"] + "extensions": ["/path/to/extension.ts"] } ``` @@ -694,12 +692,11 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `terminal.showImages` | Render images inline (supported terminals) | `true` | | `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` | | `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` | -| `hooks` | Additional hook file paths | `[]` | -| `customTools` | Additional custom tool file paths | `[]` | +| `extensions` | Additional extension file paths | `[]` | --- -## Extensions +## Customization ### Themes @@ -720,13 +717,13 @@ Select with `/settings`, then edit the file. Changes apply on save. **VS Code terminal fix:** Set `terminal.integrated.minimumContrastRatio` to `1` for accurate colors. -### Custom Slash Commands +### Prompt Templates Define reusable prompts as Markdown files: **Locations:** -- Global: `~/.pi/agent/commands/*.md` -- Project: `.pi/commands/*.md` +- Global: `~/.pi/agent/prompts/*.md` +- Project: `.pi/prompts/*.md` **Format:** @@ -755,7 +752,7 @@ Usage: `/component Button "onClick handler" "disabled support"` - `$1` = `Button` - `$@` or `$ARGUMENTS` = all arguments joined (`Button onClick handler disabled support`) -**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)` +**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md` → `/component (project:frontend)` ### Skills @@ -807,120 +804,75 @@ cd /path/to/brave-search && npm install > See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills. -### Hooks +### Extensions -Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. Use them to: +Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools, add commands, and more. -- **Block dangerous commands** (permission gates for `rm -rf`, `sudo`, etc.) -- **Checkpoint code state** (git stash at each turn, restore on `/branch`) -- **Protect paths** (block writes to `.env`, `node_modules/`, etc.) -- **Modify tool output** (filter or transform results before the LLM sees them) -- **Inject messages from external sources to wake up the agent** (file watchers, webhooks, CI systems) +**Use cases:** +- **Register custom tools** (callable by the LLM, with custom UI and rendering) +- **Intercept events** (block commands, modify context/results, customize compaction) +- **Persist state** (store custom data in session, reconstruct on reload/branch) +- **External integrations** (file watchers, webhooks, git checkpointing) -**Hook locations:** -- Global: `~/.pi/agent/hooks/*.ts` -- Project: `.pi/hooks/*.ts` -- CLI: `--hook ` (for debugging) +**Extension locations:** +- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts` +- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts` +- CLI: `--extension ` or `-e ` -**Quick example** (permission gate): +**Quick example:** ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { + // Subscribe to events pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) { const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string); if (!ok) return { block: true, reason: "Blocked by user" }; } - return undefined; + }); + + // Register a custom tool + pi.registerTool({ + name: "greet", + label: "Greeting", + description: "Generate a greeting", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, onUpdate, ctx, signal) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register a command + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify(`Hello ${args || "world"}!`, "info"); + }, }); } ``` -**Sending messages from hooks:** - -Use `pi.sendMessage(message, options?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM. - -Options: -- `triggerTurn`: If true and agent is idle, starts a new agent turn. Default: false. -- `deliverAs`: When agent is streaming, controls delivery timing: - - `"steer"` (default): Delivered after current tool execution, interrupts remaining tools. - - `"followUp"`: Delivered only after agent finishes all work. - -```typescript -import * as fs from "node:fs"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - pi.on("session_start", async () => { - fs.watch("/tmp/trigger.txt", () => { - const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim(); - if (content) { - pi.sendMessage({ - customType: "file-trigger", - content, - display: true, - }, true); // triggerTurn: start agent loop - } - }); - }); -} -``` - -> See [Hooks Documentation](docs/hooks.md) for full API reference. pi can help you create new hooks - -> See [examples/hooks/](examples/hooks/) for working examples including permission gates, git checkpointing, and path protection. - -### Custom Tools - -Custom tools let you extend the built-in toolset (read, write, edit, bash, ...) and are called by the LLM directly. They are TypeScript modules that define tools with optional custom TUI integration for getting user input and custom tool call and result rendering. - -**Tool locations (auto-discovered):** -- Global: `~/.pi/agent/tools/*/index.ts` -- Project: `.pi/tools/*/index.ts` - -**Explicit paths:** -- CLI: `--tool ` (any .ts file) -- Settings: `customTools` array in `settings.json` - -**Quick example:** - -```typescript -import { Type } from "@sinclair/typebox"; -import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; - -const factory: CustomToolFactory = (pi) => ({ - name: "greet", - label: "Greeting", - description: "Generate a greeting", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), - }), - - async execute(toolCallId, params, onUpdate, ctx, signal) { - const { name } = params as { name: string }; - return { - content: [{ type: "text", text: `Hello, ${name}!` }], - details: { greeted: name }, - }; - }, -}); - -export default factory; -``` - **Features:** -- Access to `pi.cwd`, `pi.exec()`, `pi.ui` (select/confirm/input dialogs) -- Session lifecycle via `onSession` callback (for state reconstruction) -- Custom rendering via `renderCall()` and `renderResult()` methods -- Streaming results via `onUpdate` callback -- Abort handling via `signal` parameter -- Multiple tools from one factory (return an array) +- Event handlers: `pi.on("tool_call", ...)`, `pi.on("session_start", ...)`, etc. +- Custom tools: `pi.registerTool({ name, execute, renderResult, ... })` +- Commands: `pi.registerCommand("name", { handler })` +- Keyboard shortcuts: `pi.registerShortcut("ctrl+x", { handler })` +- CLI flags: `pi.registerFlag("--my-flag", { ... })` +- UI access: `ctx.ui.confirm()`, `ctx.ui.select()`, `ctx.ui.input()` +- Shell execution: `pi.exec("git", ["status"])` +- Message injection: `pi.sendMessage({ content, ... }, { triggerTurn: true })` -> See [Custom Tools Documentation](docs/custom-tools.md) for the full API reference, TUI component guide, and examples. pi can help you create custom tools. +> See [Extensions Documentation](docs/extensions.md) for full API reference. pi can help you create extensions. -> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction. +> See [examples/extensions/](examples/extensions/) for working examples. --- @@ -949,7 +901,7 @@ pi [options] [@files...] [messages...] | `--models ` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) | | `--tools ` | Comma-separated tool list (default: `read,bash,edit,write`) | | `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` | -| `--hook ` | Load a hook file (can be used multiple times) | +| `--extension `, `-e` | Load an extension file (can be used multiple times) | | `--no-skills` | Disable skills discovery and loading | | `--skills ` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) | | `--export [output]` | Export session to HTML | @@ -1033,7 +985,7 @@ Available via `--tools` flag: Example: `--tools read,grep,find,ls` for code review without modification. -For adding new tools, see [Custom Tools](#custom-tools) in the Configuration section. +For adding new tools, see [Extensions](#extensions) in the Customization section. --- @@ -1068,8 +1020,8 @@ The SDK provides full control over: - Model selection and thinking level - System prompt (replace or modify) - Tools (built-in subsets, custom tools) -- Hooks (inline or discovered) -- Skills, context files, slash commands +- Extensions (discovered or via paths) +- Skills, context files, prompt templates - Session persistence (`SessionManager`) - Settings (`SettingsManager`) - API key resolution and OAuth @@ -1111,13 +1063,13 @@ Pi is opinionated about what it won't do. These are intentional design decisions **No MCP.** Build CLI tools with READMEs (see [Skills](#skills)). The agent reads them on demand. [Would you like to know more?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/) -**No sub-agents.** Spawn pi instances via tmux, or [build your own sub-agent tool](examples/custom-tools/subagent/) with [custom tools](#custom-tools). Full observability and steerability. +**No sub-agents.** Spawn pi instances via tmux, or [build your own sub-agent tool](examples/extensions/subagent/) with [Extensions](#extensions). Full observability and steerability. -**No permission popups.** Security theater. Run in a container or build your own with [Hooks](#hooks). +**No permission popups.** Security theater. Run in a container or build your own with [Extensions](#extensions). **No plan mode.** Gather context in one session, write plans to file, start fresh for implementation. -**No built-in to-dos.** They confuse models. Use a TODO.md file, or [build your own](examples/custom-tools/todo/) with [custom tools](#custom-tools). +**No built-in to-dos.** They confuse models. Use a TODO.md file, or [build your own](examples/extensions/todo.ts) with [Extensions](#extensions). **No background bash.** Use tmux. Full observability, direct interaction. diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md deleted file mode 100644 index 8511aac9..00000000 --- a/packages/coding-agent/docs/custom-tools.md +++ /dev/null @@ -1,550 +0,0 @@ -> pi can create custom tools. Ask it to build one for your use case. - -# Custom Tools - -Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering. - -**Key capabilities:** -- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs) -- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult` -- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md)) -- **State management** - Persist state in tool result `details` for proper branching support -- **Streaming results** - Send partial updates via `onUpdate` callback - -**Example use cases:** -- Interactive dialogs (questions with selectable options) -- Stateful tools (todo lists, connection pools) -- Rich output rendering (progress indicators, structured views) -- External service integrations with confirmation flows - -**When to use custom tools vs. alternatives:** - -| Need | Solution | -|------|----------| -| Always-needed context (conventions, commands) | AGENTS.md | -| User triggers a specific prompt template | Slash command | -| On-demand capability package (workflows, scripts, setup) | Skill | -| Additional tool directly callable by the LLM | **Custom tool** | - -See [examples/custom-tools/](../examples/custom-tools/) for working examples. - -## Quick Start - -Create a file `~/.pi/agent/tools/hello/index.ts`: - -```typescript -import { Type } from "@sinclair/typebox"; -import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; - -const factory: CustomToolFactory = (pi) => ({ - name: "hello", - label: "Hello", - description: "A simple greeting tool", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), - }), - - async execute(toolCallId, params, onUpdate, ctx, signal) { - const { name } = params as { name: string }; - return { - content: [{ type: "text", text: `Hello, ${name}!` }], - details: { greeted: name }, - }; - }, -}); - -export default factory; -``` - -The tool is automatically discovered and available in your next pi session. - -## Tool Locations - -Tools must be in a subdirectory with an `index.ts` entry point: - -| Location | Scope | Auto-discovered | -|----------|-------|-----------------| -| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes | -| `.pi/tools/*/index.ts` | Project-local | Yes | -| `settings.json` `customTools` array | Configured paths | Yes | -| `--tool ` CLI flag | One-off/debugging | No | - -**Example structure:** -``` -~/.pi/agent/tools/ -├── hello/ -│ └── index.ts # Entry point (auto-discovered) -└── complex-tool/ - ├── index.ts # Entry point (auto-discovered) - ├── helpers.ts # Helper module (not loaded directly) - └── types.ts # Type definitions (not loaded directly) -``` - -**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority. - -**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`). - -## Available Imports - -Custom tools can import from these packages (automatically resolved by pi): - -| Package | Purpose | -|---------|---------| -| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | -| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) | -| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | -| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | - -Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available. - -## Tool Definition - -```typescript -import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { Text } from "@mariozechner/pi-tui"; -import type { - CustomTool, - CustomToolContext, - CustomToolFactory, - CustomToolSessionEvent, -} from "@mariozechner/pi-coding-agent"; - -const factory: CustomToolFactory = (pi) => ({ - name: "my_tool", - label: "My Tool", - description: "What this tool does (be specific for LLM)", - parameters: Type.Object({ - // Use StringEnum for string enums (Google API compatible) - action: StringEnum(["list", "add", "remove"] as const), - text: Type.Optional(Type.String()), - }), - - async execute(toolCallId, params, onUpdate, ctx, signal) { - // signal - AbortSignal for cancellation - // onUpdate - Callback for streaming partial results - // ctx - CustomToolContext with sessionManager, modelRegistry, model - return { - content: [{ type: "text", text: "Result for LLM" }], - details: { /* structured data for rendering */ }, - }; - }, - - // Optional: Session lifecycle callback - onSession(event, ctx) { - if (event.reason === "shutdown") { - // Cleanup resources (close connections, save state, etc.) - return; - } - // Reconstruct state from ctx.sessionManager.getBranch() - }, - - // Optional: Custom rendering - renderCall(args, theme) { /* return Component */ }, - renderResult(result, options, theme) { /* return Component */ }, -}); - -export default factory; -``` - -**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API. - -## CustomToolAPI Object - -The factory receives a `CustomToolAPI` object (named `pi` by convention): - -```typescript -interface CustomToolAPI { - cwd: string; // Current working directory - exec(command: string, args: string[], options?: ExecOptions): Promise; - ui: ToolUIContext; - hasUI: boolean; // false in --print or --mode rpc - events: EventBus; // Shared event bus for tool/hook communication - sendMessage(message, options?): void; // Send messages to the agent session -} - -interface ToolUIContext { - select(title: string, options: string[]): Promise; - confirm(title: string, message: string): Promise; - input(title: string, placeholder?: string): Promise; - notify(message: string, type?: "info" | "warning" | "error"): void; - custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; -} - -interface ExecOptions { - signal?: AbortSignal; // Cancel the process - timeout?: number; // Timeout in milliseconds -} - -interface ExecResult { - stdout: string; - stderr: string; - code: number; - killed?: boolean; // True if process was killed by signal/timeout -} -``` - -Always check `pi.hasUI` before using UI methods. - -### Event Bus - -Tools can emit events that hooks (or other tools) listen for via `pi.events`: - -```typescript -// Emit an event -pi.events.emit("mytool:completed", { result: "success", itemCount: 42 }); - -// Listen for events (tools can also subscribe) -const unsubscribe = pi.events.on("other:event", (data) => { - console.log("Received:", data); -}); -``` - -Event handlers persist across session switches (registered once at tool load time). Use namespaced channel names like `"toolname:event"` to avoid collisions. Handler errors (sync and async) are caught and logged. - -### Sending Messages - -Tools can send messages to the agent session via `pi.sendMessage()`: - -```typescript -pi.sendMessage({ - customType: "mytool-notify", - content: "Configuration was updated", - display: true, -}, { - deliverAs: "nextTurn", -}); -``` - -**Delivery modes:** `"steer"` (default) interrupts streaming, `"followUp"` waits for completion, `"nextTurn"` queues for next user message. Use `triggerTurn: true` to wake an idle agent immediately. - -See [hooks documentation](hooks.md#pisendmessagemessage-options) for full details. - -### Cancellation Example - -Pass the `signal` from `execute` to `pi.exec` to support cancellation: - -```typescript -async execute(toolCallId, params, onUpdate, ctx, signal) { - const result = await pi.exec("long-running-command", ["arg"], { signal }); - if (result.killed) { - return { content: [{ type: "text", text: "Cancelled" }] }; - } - return { content: [{ type: "text", text: result.stdout }] }; -} -``` - -### Error Handling - -**Throw an error** when the tool fails. Do not return an error message as content. - -```typescript -async execute(toolCallId, params, onUpdate, ctx, signal) { - const { path } = params as { path: string }; - - // Throw on error - pi will catch it and report to the LLM - if (!fs.existsSync(path)) { - throw new Error(`File not found: ${path}`); - } - - // Return content only on success - return { content: [{ type: "text", text: "Success" }] }; -} -``` - -Thrown errors are: -- Reported to the LLM as tool errors (with `isError: true`) -- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`) -- Displayed in the TUI with error styling - -## CustomToolContext - -The `execute` and `onSession` callbacks receive a `CustomToolContext`: - -```typescript -interface CustomToolContext { - sessionManager: ReadonlySessionManager; // Read-only access to session - modelRegistry: ModelRegistry; // For API key resolution - model: Model | undefined; // Current model (may be undefined) - isIdle(): boolean; // Whether agent is streaming - hasQueuedMessages(): boolean; // Whether user has queued messages - abort(): void; // Abort current operation (fire-and-forget) -} -``` - -Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction. - -### Checking Queue State - -Interactive tools can skip prompts when the user has already queued a message: - -```typescript -async execute(toolCallId, params, onUpdate, ctx, signal) { - // If user already queued a message, skip the interactive prompt - if (ctx.hasQueuedMessages()) { - return { - content: [{ type: "text", text: "Skipped - user has queued input" }], - }; - } - - // Otherwise, prompt for input - const answer = await pi.ui.input("What would you like to do?"); - // ... -} -``` - -### Multi-line Editor - -For longer text editing, use `pi.ui.editor()` which supports Ctrl+G for external editor: - -```typescript -async execute(toolCallId, params, onUpdate, ctx, signal) { - const text = await pi.ui.editor("Edit your response:", "prefilled text"); - // Returns edited text or undefined if cancelled (Escape) - // Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR - - if (!text) { - return { content: [{ type: "text", text: "Cancelled" }] }; - } - // ... -} -``` - -## Session Lifecycle - -Tools can implement `onSession` to react to session changes: - -```typescript -interface CustomToolSessionEvent { - reason: "start" | "switch" | "branch" | "tree" | "shutdown"; - previousSessionFile: string | undefined; -} -``` - -**Reasons:** -- `start`: Initial session load on startup -- `switch`: User started a new session (`/new`) or switched to a different session (`/resume`) -- `branch`: User branched from a previous message (`/branch`) -- `tree`: User navigated to a different point in the session tree (`/tree`) -- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources - -To check if a session is fresh (no messages), use `ctx.sessionManager.getEntries().length === 0`. - -### State Management Pattern - -Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history. - -```typescript -interface MyToolDetails { - items: string[]; -} - -const factory: CustomToolFactory = (pi) => { - // In-memory state - let items: string[] = []; - - // Reconstruct state from session entries - const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => { - if (event.reason === "shutdown") return; - - items = []; - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type !== "message") continue; - const msg = entry.message; - if (msg.role !== "toolResult") continue; - if (msg.toolName !== "my_tool") continue; - - const details = msg.details as MyToolDetails | undefined; - if (details) { - items = details.items; - } - } - }; - - return { - name: "my_tool", - label: "My Tool", - description: "...", - parameters: Type.Object({ ... }), - - onSession: reconstructState, - - async execute(toolCallId, params, onUpdate, ctx, signal) { - // Modify items... - items.push("new item"); - - return { - content: [{ type: "text", text: "Added item" }], - // Store current state in details for reconstruction - details: { items: [...items] }, - }; - }, - }; -}; -``` - -This pattern ensures: -- When user branches, state is correct for that point in history -- When user switches sessions, state matches that session -- When user starts a new session, state resets - -## Custom Rendering - -Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API. - -### How It Works - -Tool output is wrapped in a `Box` component that handles: -- Padding (1 character horizontal, 1 line vertical) -- Background color based on state (pending/success/error) - -Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding. - -### renderCall - -Renders the tool call (before/during execution): - -```typescript -renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("my_tool ")); - text += theme.fg("muted", args.action); - if (args.text) { - text += " " + theme.fg("dim", `"${args.text}"`); - } - return new Text(text, 0, 0); -} -``` - -Called when: -- Tool call starts (may have partial args during streaming) -- Args are updated during streaming - -### renderResult - -Renders the tool result: - -```typescript -renderResult(result, { expanded, isPartial }, theme) { - const { details } = result; - - // Handle streaming/partial results - if (isPartial) { - return new Text(theme.fg("warning", "Processing..."), 0, 0); - } - - // Handle errors - if (details?.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - // Normal result - let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done"); - - // Support expanded view (Ctrl+O) - if (expanded && details?.items) { - for (const item of details.items) { - text += "\n" + theme.fg("dim", ` ${item}`); - } - } - - return new Text(text, 0, 0); -} -``` - -**Options:** -- `expanded`: User pressed Ctrl+O to expand -- `isPartial`: Result is from `onUpdate` (streaming), not final - -### Best Practices - -1. **Use `Text` with padding `(0, 0)`** - The Box handles padding -2. **Use `\n` for multi-line content** - Not multiple Text components -3. **Handle `isPartial`** - Show progress during streaming -4. **Support `expanded`** - Show more detail when user requests -5. **Use theme colors** - For consistent appearance -6. **Keep it compact** - Show summary by default, details when expanded - -### Theme Colors - -```typescript -// Foreground -theme.fg("toolTitle", text) // Tool names -theme.fg("accent", text) // Highlights -theme.fg("success", text) // Success -theme.fg("error", text) // Errors -theme.fg("warning", text) // Warnings -theme.fg("muted", text) // Secondary text -theme.fg("dim", text) // Tertiary text -theme.fg("toolOutput", text) // Output content - -// Styles -theme.bold(text) -theme.italic(text) -``` - -### Fallback Behavior - -If `renderCall` or `renderResult` is not defined or throws an error: -- `renderCall`: Shows tool name -- `renderResult`: Shows raw text output from `content` - -## Execute Function - -```typescript -async execute(toolCallId, args, onUpdate, ctx, signal) { - // Type assertion for params (TypeBox schema doesn't flow through) - const params = args as { action: "list" | "add"; text?: string }; - - // Check for abort - if (signal?.aborted) { - return { content: [...], details: { status: "aborted" } }; - } - - // Stream progress - onUpdate?.({ - content: [{ type: "text", text: "Working..." }], - details: { progress: 50 }, - }); - - // Return final result - return { - content: [{ type: "text", text: "Done" }], // Sent to LLM - details: { data: result }, // For rendering only - }; -} -``` - -## Multiple Tools from One File - -Return an array to share state between related tools: - -```typescript -const factory: CustomToolFactory = (pi) => { - // Shared state - let connection = null; - - const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => { - if (event.reason === "shutdown") { - connection?.close(); - } - }; - - return [ - { name: "db_connect", onSession: handleSession, ... }, - { name: "db_query", onSession: handleSession, ... }, - { name: "db_close", onSession: handleSession, ... }, - ]; -}; -``` - -## Examples - -See [`examples/custom-tools/todo/index.ts`](../examples/custom-tools/todo/index.ts) for a complete example with: -- `onSession` for state reconstruction -- Custom `renderCall` and `renderResult` -- Proper branching support via details storage - -Test with: -```bash -pi --tool packages/coding-agent/examples/custom-tools/todo/index.ts -``` diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md new file mode 100644 index 00000000..d67b68f7 --- /dev/null +++ b/packages/coding-agent/docs/extensions.md @@ -0,0 +1,698 @@ +> pi can create extensions. Ask it to build one for your use case. + +# Extensions + +Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more. + +**Key capabilities:** +- **Custom tools** - Register tools the LLM can call via `pi.registerTool()` +- **Event interception** - Block or modify tool calls, inject context, customize compaction +- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` +- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` +- **Session persistence** - Store state that survives restarts via `pi.appendEntry()` +- **Custom rendering** - Control how tool calls/results and messages appear in TUI + +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on `/branch`) +- Path protection (block writes to `.env`, `node_modules/`) +- Interactive tools (questions, wizards, custom dialogs) +- Stateful tools (todo lists, connection pools) +- External integrations (file watchers, webhooks, CI triggers) + +See [examples/extensions/](../examples/extensions/) and [examples/hooks/](../examples/hooks/) for working implementations. + +## Quick Start + +Create `~/.pi/agent/extensions/my-extension.ts`: + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + // React to events + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify("Extension loaded!", "info"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register a custom tool + pi.registerTool({ + name: "greet", + label: "Greet", + description: "Greet someone by name", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, onUpdate, ctx, signal) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register a command + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify(`Hello ${args || "world"}!`, "info"); + }, + }); +} +``` + +Test with `--extension` (or `-e`) flag: + +```bash +pi -e ./my-extension.ts +``` + +## Extension Locations + +Extensions are auto-discovered from: + +| Location | Scope | +|----------|-------| +| `~/.pi/agent/extensions/*.ts` | Global (all projects) | +| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) | +| `.pi/extensions/*.ts` | Project-local | +| `.pi/extensions/*/index.ts` | Project-local (subdirectory) | + +Additional paths via `settings.json`: + +```json +{ + "extensions": ["/path/to/extension.ts"] +} +``` + +**Subdirectory structure with package.json:** + +``` +~/.pi/agent/extensions/ +├── simple.ts # Direct file (auto-discovered) +└── complex-extension/ + ├── package.json # Optional: { "pi": { "extensions": ["./src/main.ts"] } } + ├── index.ts # Entry point (if no package.json) + └── src/ + └── main.ts # Custom entry (via package.json) +``` + +## Available Imports + +| Package | Purpose | +|---------|---------| +| `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | +| `@sinclair/typebox` | Schema definitions for tool parameters | +| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | +| `@mariozechner/pi-tui` | TUI components for custom rendering | + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. + +## Writing an Extension + +An extension exports a default function that receives `ExtensionAPI`: + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // Handle event + }); + + // Register tools, commands, shortcuts, flags + pi.registerTool({ ... }); + pi.registerCommand("name", { ... }); + pi.registerShortcut("ctrl+x", { ... }); + pi.registerFlag("--my-flag", { ... }); +} +``` + +Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. + +## Events + +### Lifecycle Overview + +``` +pi starts + │ + └─► session_start + │ + ▼ +user sends prompt ─────────────────────────────────────────┐ + │ │ + ├─► before_agent_start (can inject message, append to system prompt) + ├─► agent_start │ + │ │ + │ ┌─── turn (repeats while LLM calls tools) ───┐ │ + │ │ │ │ + │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ + │ │ │ │ + │ │ LLM responds, may call tools: │ │ + │ │ ├─► tool_call (can block) │ │ + │ │ │ tool executes │ │ + │ │ └─► tool_result (can modify) │ │ + │ │ │ │ + │ └─► turn_end │ │ + │ │ + └─► agent_end │ + │ +user sends another prompt ◄────────────────────────────────┘ + +/new (new session) or /resume (switch session) + ├─► session_before_switch (can cancel) + └─► session_switch + +/branch + ├─► session_before_branch (can cancel) + └─► session_branch + +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact + +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +exit (Ctrl+C, Ctrl+D) + └─► session_shutdown +``` + +### Session Events + +#### session_start + +Fired on initial session load. + +```typescript +pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); +}); +``` + +#### session_before_switch / session_switch + +Fired when starting a new session (`/new`) or switching sessions (`/resume`). + +```typescript +pi.on("session_before_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.targetSessionFile - session we're switching to (only for "resume") + + if (event.reason === "new") { + const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); + if (!ok) return { cancel: true }; + } +}); + +pi.on("session_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.previousSessionFile - session we came from +}); +``` + +#### session_before_branch / session_branch + +Fired when branching via `/branch`. + +```typescript +pi.on("session_before_branch", async (event, ctx) => { + // event.entryId - ID of the entry being branched from + return { cancel: true }; // Cancel branch + // OR + return { skipConversationRestore: true }; // Branch but don't rewind messages +}); + +pi.on("session_branch", async (event, ctx) => { + // event.previousSessionFile - previous session file +}); +``` + +#### session_before_compact / session_compact + +Fired on compaction. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromExtension - whether extension provided it +}); +``` + +#### session_before_tree / session_tree + +Fired on `/tree` navigation. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + return { cancel: true }; + // OR provide custom summary: + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromExtension +}); +``` + +#### session_shutdown + +Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). + +```typescript +pi.on("session_shutdown", async (_event, ctx) => { + // Cleanup, save state, etc. +}); +``` + +### Agent Events + +#### before_agent_start + +Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt. + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt - user's prompt text + // event.images - attached images (if any) + + return { + // Inject a persistent message (stored in session, sent to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Append to system prompt for this turn only + systemPromptAppend: "Extra instructions for this turn...", + }; +}); +``` + +#### agent_start / agent_end + +Fired once per user prompt. + +```typescript +pi.on("agent_start", async (_event, ctx) => {}); + +pi.on("agent_end", async (event, ctx) => { + // event.messages - messages from this prompt +}); +``` + +#### turn_start / turn_end + +Fired for each turn (one LLM response + tool calls). + +```typescript +pi.on("turn_start", async (event, ctx) => { + // event.turnIndex, event.timestamp +}); + +pi.on("turn_end", async (event, ctx) => { + // event.turnIndex, event.message, event.toolResults +}); +``` + +#### context + +Fired before each LLM call. Modify messages non-destructively. + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages - deep copy, safe to modify + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +### Tool Events + +#### tool_call + +Fired before tool executes. **Can block.** + +```typescript +pi.on("tool_call", async (event, ctx) => { + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters + + if (shouldBlock(event)) { + return { block: true, reason: "Not allowed" }; + } +}); +``` + +#### tool_result + +Fired after tool executes. **Can modify result.** + +```typescript +import { isBashToolResult } from "@mariozechner/pi-coding-agent"; + +pi.on("tool_result", async (event, ctx) => { + // event.toolName, event.toolCallId, event.input + // event.content, event.details, event.isError + + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + + // Modify result: + return { content: [...], details: {...}, isError: false }; +}); +``` + +## ExtensionContext + +Every handler receives `ctx: ExtensionContext`: + +### ctx.ui + +UI methods for user interaction: + +```typescript +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); + +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); + +// Text input +const name = await ctx.ui.input("Name:", "placeholder"); + +// Multi-line editor +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Notification +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" + +// Status in footer +ctx.ui.setStatus("my-ext", "Processing..."); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Widget above editor +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); +ctx.ui.setWidget("my-widget", undefined); // Clear + +// Terminal title +ctx.ui.setTitle("pi - my-project"); + +// Editor text +ctx.ui.setEditorText("Prefill text"); +const current = ctx.ui.getEditorText(); +``` + +**Custom components:** + +```typescript +const result = await ctx.ui.custom((tui, theme, done) => { + const component = new MyComponent(); + component.onComplete = (value) => done(value); + return component; +}); +``` + +### ctx.hasUI + +`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`. + +### ctx.cwd + +Current working directory. + +### ctx.sessionManager + +Read-only access to session state: + +```typescript +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID +``` + +### ctx.modelRegistry / ctx.model + +Access to models and API keys. + +### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() + +Control flow helpers. + +## ExtensionCommandContext + +Slash command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with: + +```typescript +await ctx.waitForIdle(); // Wait for agent to finish +await ctx.newSession({ ... }); // Create new session +await ctx.branch(entryId); // Branch from entry +await ctx.navigateTree(targetId); // Navigate tree +``` + +## ExtensionAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events). + +### pi.registerTool(definition) + +Register a custom tool callable by the LLM: + +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String()), + }), + + async execute(toolCallId, params, onUpdate, ctx, signal) { + // Stream progress + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + return { + content: [{ type: "text", text: "Done" }], + details: { result: "..." }, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme) { + return new Text(theme.fg("toolTitle", "my_tool ") + args.action, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text("Working...", 0, 0); + return new Text(theme.fg("success", "✓ Done"), 0, 0); + }, +}); +``` + +**Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums (Google API compatible). + +### pi.sendMessage(message, options?) + +Inject a message into the session: + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Message text", + display: true, + details: { ... }, +}, { + triggerTurn: true, // Trigger LLM response if idle + deliverAs: "steer", // "steer", "followUp", or "nextTurn" +}); +``` + +### pi.appendEntry(customType, data?) + +Persist extension state (does NOT participate in LLM context): + +```typescript +pi.appendEntry("my-state", { count: 42 }); + +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + // Reconstruct from entry.data + } + } +}); +``` + +### pi.registerCommand(name, options) + +Register a command: + +```typescript +pi.registerCommand("stats", { + description: "Show session statistics", + handler: async (args, ctx) => { + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); + } +}); +``` + +### pi.registerMessageRenderer(customType, renderer) + +Register a custom TUI renderer for messages with your `customType`: + +```typescript +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + return new Text(theme.fg("accent", `[INFO] `) + message.content, 0, 0); +}); +``` + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut: + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + ctx.ui.notify("Toggled!"); + }, +}); +``` + +### pi.registerFlag(name, options) + +Register a CLI flag: + +```typescript +pi.registerFlag("--plan", { + description: "Start in plan mode", + type: "boolean", + default: false, +}); + +// Check value +if (pi.getFlag("--plan")) { + // Plan mode enabled +} +``` + +### pi.exec(command, args, options?) + +Execute a shell command: + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) + +Manage active tools: + +```typescript +const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] +pi.setActiveTools(["read", "bash"]); // Switch to read-only +``` + +### pi.events + +Shared event bus for communication between extensions: + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +## State Management + +Extensions with state should store it in tool result `details` for proper branching support: + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session + pi.on("session_start", async (_event, ctx) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }); + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, onUpdate, ctx, signal) { + items.push("new item"); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // Store for reconstruction + }; + }, + }); +} +``` + +## Error Handling + +- Extension errors are logged, agent continues +- `tool_call` errors block the tool (fail-safe) +- Tool `execute` errors are reported to the LLM with `isError: true` + +## Mode Behavior + +| Mode | UI Methods | Notes | +|------|-----------|-------| +| Interactive | Full TUI | Normal operation | +| RPC | JSON protocol | Host handles UI | +| Print (`-p`) | No-op | Extensions run but can't prompt | + +In print mode, check `ctx.hasUI` before using UI methods. diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md deleted file mode 100644 index 91b473b6..00000000 --- a/packages/coding-agent/docs/hooks.md +++ /dev/null @@ -1,1016 +0,0 @@ -> pi can create hooks. Ask it to build one for your use case. - -# Hooks - -Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more. - -**Key capabilities:** -- **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify) -- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` -- **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()` -- **Event interception** - Block or modify tool calls, inject context, customize compaction -- **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()` - -**Example use cases:** -- Permission gates (confirm before `rm -rf`, `sudo`, etc.) -- Git checkpointing (stash at each turn, restore on `/branch`) -- Path protection (block writes to `.env`, `node_modules/`) -- External integrations (file watchers, webhooks, CI triggers) -- Interactive tools (games, wizards, custom dialogs) - -See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI. - -## Quick Start - -Create `~/.pi/agent/hooks/my-hook.ts`: - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: HookAPI) { - pi.on("session_start", async (_event, ctx) => { - ctx.ui.notify("Hook loaded!", "info"); - }); - - pi.on("tool_call", async (event, ctx) => { - if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { - const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); - if (!ok) return { block: true, reason: "Blocked by user" }; - } - }); -} -``` - -Test with `--hook` flag: - -```bash -pi --hook ./my-hook.ts -``` - -## Hook Locations - -Hooks are auto-discovered from: - -| Location | Scope | -|----------|-------| -| `~/.pi/agent/hooks/*.ts` | Global (all projects) | -| `.pi/hooks/*.ts` | Project-local | - -Additional paths via `settings.json`: - -```json -{ - "hooks": ["/path/to/hook.ts"] -} -``` - -## Available Imports - -| Package | Purpose | -|---------|---------| -| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) | -| `@mariozechner/pi-coding-agent` | Additional types if needed | -| `@mariozechner/pi-ai` | AI utilities | -| `@mariozechner/pi-tui` | TUI components | - -Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. - -## Writing a Hook - -A hook exports a default function that receives `HookAPI`: - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: HookAPI) { - // Subscribe to events - pi.on("event_name", async (event, ctx) => { - // Handle event - }); -} -``` - -Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. - -## Events - -### Lifecycle Overview - -``` -pi starts - │ - └─► session_start - │ - ▼ -user sends prompt ─────────────────────────────────────────┐ - │ │ - ├─► before_agent_start (can inject message, append to system prompt) │ - ├─► agent_start │ - │ │ - │ ┌─── turn (repeats while LLM calls tools) ───┐ │ - │ │ │ │ - │ ├─► turn_start │ │ - │ ├─► context (can modify messages) │ │ - │ │ │ │ - │ │ LLM responds, may call tools: │ │ - │ │ ├─► tool_call (can block) │ │ - │ │ │ tool executes │ │ - │ │ └─► tool_result (can modify) │ │ - │ │ │ │ - │ └─► turn_end │ │ - │ │ - └─► agent_end │ - │ -user sends another prompt ◄────────────────────────────────┘ - -/new (new session) or /resume (switch session) - ├─► session_before_switch (can cancel, has reason: "new" | "resume") - └─► session_switch (has reason: "new" | "resume") - -/branch - ├─► session_before_branch (can cancel) - └─► session_branch - -/compact or auto-compaction - ├─► session_before_compact (can cancel or customize) - └─► session_compact - -/tree navigation - ├─► session_before_tree (can cancel or customize) - └─► session_tree - -exit (Ctrl+C, Ctrl+D) - └─► session_shutdown -``` - -### Session Events - -#### session_start - -Fired on initial session load. - -```typescript -pi.on("session_start", async (_event, ctx) => { - ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); -}); -``` - -#### session_before_switch / session_switch - -Fired when starting a new session (`/new`) or switching sessions (`/resume`). - -```typescript -pi.on("session_before_switch", async (event, ctx) => { - // event.reason - "new" (starting fresh) or "resume" (switching to existing) - // event.targetSessionFile - session we're switching to (only for "resume") - - if (event.reason === "new") { - const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); - if (!ok) return { cancel: true }; - } - - return { cancel: true }; // Cancel the switch/new -}); - -pi.on("session_switch", async (event, ctx) => { - // event.reason - "new" or "resume" - // event.previousSessionFile - session we came from -}); -``` - -#### session_before_branch / session_branch - -Fired when branching via `/branch`. - -```typescript -pi.on("session_before_branch", async (event, ctx) => { - // event.entryId - ID of the entry being branched from - - return { cancel: true }; // Cancel branch - // OR - return { skipConversationRestore: true }; // Branch but don't rewind messages -}); - -pi.on("session_branch", async (event, ctx) => { - // event.previousSessionFile - previous session file -}); -``` - -The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately. - -#### session_before_compact / session_compact - -Fired on compaction. See [compaction.md](compaction.md) for details. - -```typescript -pi.on("session_before_compact", async (event, ctx) => { - const { preparation, branchEntries, customInstructions, signal } = event; - - // Cancel: - return { cancel: true }; - - // Custom summary: - return { - compaction: { - summary: "...", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - } - }; -}); - -pi.on("session_compact", async (event, ctx) => { - // event.compactionEntry - the saved compaction - // event.fromHook - whether hook provided it -}); -``` - -#### session_before_tree / session_tree - -Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details. - -```typescript -pi.on("session_before_tree", async (event, ctx) => { - const { preparation, signal } = event; - // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize - // preparation.userWantsSummary - whether user chose to summarize - - return { cancel: true }; - // OR provide custom summary (only used if userWantsSummary is true): - return { summary: { summary: "...", details: {} } }; -}); - -pi.on("session_tree", async (event, ctx) => { - // event.newLeafId, oldLeafId, summaryEntry, fromHook -}); -``` - -#### session_shutdown - -Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). - -```typescript -pi.on("session_shutdown", async (_event, ctx) => { - // Cleanup, save state, etc. -}); -``` - -### Agent Events - -#### before_agent_start - -Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt. - -```typescript -pi.on("before_agent_start", async (event, ctx) => { - // event.prompt - user's prompt text - // event.images - attached images (if any) - - return { - // Inject a persistent message (stored in session, sent to LLM) - message: { - customType: "my-hook", - content: "Additional context for the LLM", - display: true, // Show in TUI - }, - // Append to system prompt for this turn only - systemPromptAppend: "Extra instructions for this turn...", - }; -}); -``` - -**message**: Persisted as `CustomMessageEntry` and sent to the LLM. Multiple hooks can each return a message; all are injected in order. - -**systemPromptAppend**: Appended to the base system prompt for this agent run only. Multiple hooks can each return `systemPromptAppend` strings, which are concatenated. This is useful for dynamic instructions based on hook state (e.g., plan mode, persona toggles). - -See [examples/hooks/pirate.ts](../examples/hooks/pirate.ts) for an example using `systemPromptAppend`. - -#### agent_start / agent_end - -Fired once per user prompt. - -```typescript -pi.on("agent_start", async (_event, ctx) => {}); - -pi.on("agent_end", async (event, ctx) => { - // event.messages - messages from this prompt -}); -``` - -#### turn_start / turn_end - -Fired for each turn (one LLM response + tool calls). - -```typescript -pi.on("turn_start", async (event, ctx) => { - // event.turnIndex, event.timestamp -}); - -pi.on("turn_end", async (event, ctx) => { - // event.turnIndex - // event.message - assistant's response - // event.toolResults - tool results from this turn -}); -``` - -#### context - -Fired before each LLM call. Modify messages non-destructively (session unchanged). - -```typescript -pi.on("context", async (event, ctx) => { - // event.messages - deep copy, safe to modify - - // Filter or transform messages - const filtered = event.messages.filter(m => !shouldPrune(m)); - return { messages: filtered }; -}); -``` - -### Tool Events - -#### tool_call - -Fired before tool executes. **Can block.** - -```typescript -pi.on("tool_call", async (event, ctx) => { - // event.toolName - "bash", "read", "write", "edit", etc. - // event.toolCallId - // event.input - tool parameters - - if (shouldBlock(event)) { - return { block: true, reason: "Not allowed" }; - } -}); -``` - -Tool inputs: -- `bash`: `{ command, timeout? }` -- `read`: `{ path, offset?, limit? }` -- `write`: `{ path, content }` -- `edit`: `{ path, oldText, newText }` -- `ls`: `{ path?, limit? }` -- `find`: `{ pattern, path?, limit? }` -- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }` - -#### tool_result - -Fired after tool executes (including errors). **Can modify result.** - -Check `event.isError` to distinguish successful executions from failures. - -```typescript -pi.on("tool_result", async (event, ctx) => { - // event.toolName, event.toolCallId, event.input - // event.content - array of TextContent | ImageContent - // event.details - tool-specific (see below) - // event.isError - true if the tool threw an error - - if (event.isError) { - // Handle error case - } - - // Modify result: - return { content: [...], details: {...}, isError: false }; -}); -``` - -Use type guards for typed details: - -```typescript -import { isBashToolResult } from "@mariozechner/pi-coding-agent"; - -pi.on("tool_result", async (event, ctx) => { - if (isBashToolResult(event)) { - // event.details is BashToolDetails | undefined - if (event.details?.truncation?.truncated) { - // Full output at event.details.fullOutputPath - } - } -}); -``` - -Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`. - -## HookContext - -Every handler receives `ctx: HookContext`: - -### ctx.ui - -UI methods for user interaction. Hooks can prompt users and even render custom TUI components. - -**Built-in dialogs:** - -```typescript -// Select from options -const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); -// Returns selected string or undefined if cancelled - -// Confirm dialog -const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); -// Returns true or false - -// Text input (single line) -const name = await ctx.ui.input("Name:", "placeholder"); -// Returns string or undefined if cancelled - -// Multi-line editor (with Ctrl+G for external editor) -const text = await ctx.ui.editor("Edit prompt:", "prefilled text"); -// Returns edited text or undefined if cancelled (Escape) -// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR - -// Notification (non-blocking) -ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" - -// Set status text in footer (persistent until cleared) -ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status -ctx.ui.setStatus("my-hook", undefined); // Clear status - -// Set a multi-line widget (displayed above editor, below "Working..." indicator) -ctx.ui.setWidget("my-todos", [ - theme.fg("accent", "Plan Progress:"), - theme.fg("success", "☑ ") + theme.fg("muted", theme.strikethrough("Read files")), - theme.fg("muted", "☐ ") + "Modify code", - theme.fg("muted", "☐ ") + "Run tests", -]); -ctx.ui.setWidget("my-todos", undefined); // Clear widget - -// Set the terminal window/tab title -ctx.ui.setTitle("pi - my-project"); - -// Set the core input editor text (pre-fill prompts, generated content) -ctx.ui.setEditorText("Generated prompt text here..."); - -// Get current editor text -const currentText = ctx.ui.getEditorText(); -``` - -**Status text notes:** -- Multiple hooks can set their own status using unique keys -- Statuses are displayed on a single line in the footer, sorted alphabetically by key -- Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width -- Use `ctx.ui.theme` to style status text with theme colors (see below) - -**Widget notes:** -- Widgets are multi-line displays shown above the editor (below "Working..." indicator) -- Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically) -- `setWidget()` accepts either a string array or a component factory function -- Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`) -- **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets. - -**Terminal title notes:** -- Uses OSC escape sequence (works in most modern terminals like iTerm2, Terminal.app, Windows Terminal) -- Useful for showing project name, current task, or session status in the terminal tab/window title - -**Custom widget components:** - -For more complex widgets, pass a factory function to `setWidget()`: - -```typescript -ctx.ui.setWidget("my-widget", (tui, theme) => { - // Return any Component that implements render(width): string[] - return new MyCustomComponent(tui, theme); -}); - -// Clear the widget -ctx.ui.setWidget("my-widget", undefined); -``` - -Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor. - -**Styling with theme colors:** - -Use `ctx.ui.theme` to apply consistent colors that respect the user's theme: - -```typescript -const theme = ctx.ui.theme; - -// Foreground colors -ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + theme.fg("dim", " Ready")); -ctx.ui.setStatus("my-hook", theme.fg("error", "✗") + theme.fg("dim", " Failed")); -ctx.ui.setStatus("my-hook", theme.fg("accent", "●") + theme.fg("dim", " Working...")); - -// Available fg colors: accent, success, error, warning, muted, dim, text, and more -// See docs/theme.md for the full list of theme colors -``` - -See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a complete example. - -**Custom components:** - -Show a custom TUI component with keyboard focus: - -```typescript -import { BorderedLoader } from "@mariozechner/pi-coding-agent"; - -const result = await ctx.ui.custom((tui, theme, done) => { - const loader = new BorderedLoader(tui, theme, "Working..."); - loader.onAbort = () => done(null); - - doWork(loader.signal).then(done).catch(() => done(null)); - - return loader; // Return the component directly, do NOT wrap in Box/Container -}); -``` - -**Important:** Return your component directly from the callback. Do not wrap it in a `Box` or `Container`, as this breaks input handling. - -Your component can: -- Implement `handleInput(data: string)` to receive keyboard input -- Implement `render(width: number): string[]` to render lines -- Implement `invalidate()` to clear cached render -- Implement `dispose()` for cleanup when closed -- Call `tui.requestRender()` to trigger re-render -- Call `done(result)` when done to restore normal UI - -See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API. - -### ctx.hasUI - -`false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`: - -```typescript -if (ctx.hasUI) { - const choice = await ctx.ui.select(...); -} else { - // Default behavior -} -``` - -### ctx.cwd - -Current working directory. - -### ctx.sessionManager - -Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts). - -```typescript -// Session info -ctx.sessionManager.getCwd() // Working directory -ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions) -ctx.sessionManager.getSessionId() // Current session ID -ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session) - -// Entries -ctx.sessionManager.getEntries() // All entries (excludes header) -ctx.sessionManager.getHeader() // Session header entry -ctx.sessionManager.getEntry(id) // Specific entry by ID -ctx.sessionManager.getLabel(id) // Entry label (if any) - -// Tree navigation -ctx.sessionManager.getBranch() // Current branch (root to leaf) -ctx.sessionManager.getBranch(leafId) // Specific branch -ctx.sessionManager.getTree() // Full tree structure -ctx.sessionManager.getLeafId() // Current leaf entry ID -ctx.sessionManager.getLeafEntry() // Current leaf entry -``` - -Use `pi.sendMessage()` or `pi.appendEntry()` for writes. - -### ctx.modelRegistry - -Access to models and API keys: - -```typescript -// Get API key for a model -const apiKey = await ctx.modelRegistry.getApiKey(model); - -// Get available models -const models = ctx.modelRegistry.getAvailableModels(); -``` - -### ctx.model - -Current model, or `undefined` if none selected yet. Use for LLM calls in hooks: - -```typescript -if (ctx.model) { - const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); - // Use with @mariozechner/pi-ai complete() -} -``` - -### ctx.isIdle() - -Returns `true` if the agent is not currently streaming: - -```typescript -if (ctx.isIdle()) { - // Agent is not processing -} -``` - -### ctx.abort() - -Abort the current agent operation (fire-and-forget, does not wait): - -```typescript -await ctx.abort(); -``` - -### ctx.hasPendingMessages() - -Check if there are messages pending (user typed while agent was streaming): - -```typescript -if (ctx.hasPendingMessages()) { - // Skip interactive prompt, let pending messages take over - return; -} -``` - -## HookCommandContext (Slash Commands Only) - -Slash command handlers receive `HookCommandContext`, which extends `HookContext` with session control methods. These methods are only safe in user-initiated commands because they can cause deadlocks if called from event handlers (which run inside the agent loop). - -### ctx.waitForIdle() - -Wait for the agent to finish streaming: - -```typescript -await ctx.waitForIdle(); -// Agent is now idle -``` - -### ctx.newSession(options?) - -Create a new session, optionally with initialization: - -```typescript -const result = await ctx.newSession({ - parentSession: ctx.sessionManager.getSessionFile(), // Track lineage - setup: async (sm) => { - // Initialize the new session - sm.appendMessage({ - role: "user", - content: [{ type: "text", text: "Context from previous session..." }], - timestamp: Date.now(), - }); - }, -}); - -if (result.cancelled) { - // A hook cancelled the new session -} -``` - -### ctx.branch(entryId) - -Branch from a specific entry, creating a new session file: - -```typescript -const result = await ctx.branch("entry-id-123"); -if (!result.cancelled) { - // Now in the branched session -} -``` - -### ctx.navigateTree(targetId, options?) - -Navigate to a different point in the session tree: - -```typescript -const result = await ctx.navigateTree("entry-id-456", { - summarize: true, // Summarize the abandoned branch -}); -``` - -## HookAPI Methods - -### pi.on(event, handler) - -Subscribe to events. See [Events](#events) for all event types. - -### pi.sendMessage(message, options?) - -Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context. - -```typescript -pi.sendMessage({ - customType: "my-hook", // Your hook's identifier - content: "Message text", // string or (TextContent | ImageContent)[] - display: true, // Show in TUI - details: { ... }, // Optional metadata (not sent to LLM) -}, { - triggerTurn: true, // If true and agent is idle, triggers LLM response - deliverAs: "steer", // "steer", "followUp", or "nextTurn" -}); -``` - -**Delivery modes (`deliverAs`):** - -| Mode | When agent is streaming | When agent is idle | -|------|------------------------|-------------------| -| `"steer"` (default) | Delivered after current tool, interrupts remaining | Appended to session immediately | -| `"followUp"` | Delivered after agent finishes all work | Appended to session immediately | -| `"nextTurn"` | Queued as context for next user message | Queued as context for next user message | - -The `"nextTurn"` mode is useful for notifications that shouldn't wake the agent but should be seen on the next turn. The message becomes an "aside" - included alongside the next user prompt as context, rather than appearing as a standalone entry or triggering immediate response. - -```typescript -// Example: Notify agent about tool changes without interrupting -pi.sendMessage( - { customType: "notify", content: "Tool configuration was updated", display: true }, - { deliverAs: "nextTurn" } -); -// On next user message, agent sees this as context -``` - -**`triggerTurn` option:** -- If `triggerTurn: true` and the agent is idle, a new agent loop starts immediately -- Ignored when streaming (use `deliverAs` to control timing instead) -- Ignored when `deliverAs: "nextTurn"` (the message waits for user input) - -**LLM context:** -- `CustomMessageEntry` is converted to a user message when building context for the LLM -- Only `content` is sent to the LLM; `details` is for rendering/state only - -**TUI display:** -- If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) -- If `display: false`, the message is hidden from the TUI but still sent to the LLM -- Use `pi.registerMessageRenderer()` to customize how your messages render (see below) - -### pi.appendEntry(customType, data?) - -Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context). - -```typescript -// Save state -pi.appendEntry("my-hook-state", { count: 42 }); - -// Restore on reload -pi.on("session_start", async (_event, ctx) => { - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type === "custom" && entry.customType === "my-hook-state") { - // Reconstruct from entry.data - } - } -}); -``` - -### pi.registerCommand(name, options) - -Register a custom slash command: - -```typescript -pi.registerCommand("stats", { - description: "Show session statistics", - handler: async (args, ctx) => { - // args = everything after /stats - const count = ctx.sessionManager.getEntries().length; - ctx.ui.notify(`${count} entries`, "info"); - } -}); -``` - -For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts). - -To trigger LLM after command, call `pi.sendMessage(..., { triggerTurn: true })`. - -### pi.registerMessageRenderer(customType, renderer) - -Register a custom TUI renderer for `CustomMessageEntry` messages with your `customType`. Without a custom renderer, messages display with default purple styling showing the content as-is. - -```typescript -import { Text } from "@mariozechner/pi-tui"; - -pi.registerMessageRenderer("my-hook", (message, options, theme) => { - // message.content - the message content (string or content array) - // message.details - your custom metadata - // options.expanded - true if user pressed Ctrl+O - - const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `); - const text = typeof message.content === "string" - ? message.content - : message.content.map(c => c.type === "text" ? c.text : "[image]").join(""); - - return new Text(prefix + theme.fg("text", text), 0, 0); -}); -``` - -**Renderer signature:** -```typescript -type HookMessageRenderer = ( - message: CustomMessageEntry, - options: { expanded: boolean }, - theme: Theme -) => Component | null; -``` - -Return `null` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details. - -### pi.exec(command, args, options?) - -Execute a shell command: - -```typescript -const result = await pi.exec("git", ["status"], { - signal, // AbortSignal - timeout, // Milliseconds -}); - -// result.stdout, result.stderr, result.code, result.killed -``` - -### pi.getActiveTools() - -Get the names of currently active tools: - -```typescript -const toolNames = pi.getActiveTools(); -// ["read", "bash", "edit", "write"] -``` - -### pi.getAllTools() - -Get all configured tools (built-in via --tools or default, plus custom tools): - -```typescript -const allTools = pi.getAllTools(); -// ["read", "bash", "edit", "write", "my-custom-tool"] -``` - -### pi.setActiveTools(toolNames) - -Set the active tools by name. Changes take effect on the next agent turn. -Note: This will invalidate prompt caching for the next request. - -```typescript -// Switch to read-only mode (plan mode) -pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); - -// Restore full access -pi.setActiveTools(["read", "bash", "edit", "write"]); -``` - -Both built-in and custom tools can be enabled/disabled. Unknown tool names are ignored. - -### pi.registerFlag(name, options) - -Register a CLI flag for this hook. Flag values are accessible via `pi.getFlag()`. - -```typescript -pi.registerFlag("plan", { - description: "Start in plan mode (read-only)", - type: "boolean", // or "string" - default: false, -}); -``` - -### pi.getFlag(name) - -Get the value of a CLI flag registered by this hook. - -```typescript -if (pi.getFlag("plan") === true) { - // plan mode enabled via --plan flag -} -``` - -### pi.registerShortcut(shortcut, options) - -Register a keyboard shortcut for this hook. The handler is called when the shortcut is pressed. - -```typescript -pi.registerShortcut("shift+p", { - description: "Toggle plan mode", - handler: async (ctx) => { - // toggle mode - ctx.ui.notify("Plan mode toggled"); - }, -}); -``` - -Shortcut format: `modifier+key` where modifier can be `shift`, `ctrl`, `alt`, or combinations like `ctrl+shift`. - -### pi.events - -Shared event bus for communication between hooks and custom tools. Tools can emit events, hooks can listen and wake the agent. - -```typescript -// Listen for events and wake agent when received -pi.events.on("task:complete", (data) => { - pi.sendMessage( - { customType: "task-notify", content: `Task done: ${data}`, display: true }, - { triggerTurn: true } // Required to wake the agent - ); -}); - -// Unsubscribe when needed -const unsubscribe = pi.events.on("my:channel", handler); -unsubscribe(); -``` - -Event handlers persist across session switches (registered once at hook load time). Channel names are arbitrary strings; use namespaced names like `"toolname:event"` to avoid collisions. Handler errors (sync and async) are caught and logged. - -## Examples - -### Permission Gate - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: HookAPI) { - const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i]; - - pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "bash") return; - - const cmd = event.input.command as string; - if (dangerous.some(p => p.test(cmd))) { - if (!ctx.hasUI) { - return { block: true, reason: "Dangerous (no UI)" }; - } - const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`); - if (!ok) return { block: true, reason: "Blocked by user" }; - } - }); -} -``` - -### Protected Paths - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: HookAPI) { - const protectedPaths = [".env", ".git/", "node_modules/"]; - - pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "write" && event.toolName !== "edit") return; - - const path = event.input.path as string; - if (protectedPaths.some(p => path.includes(p))) { - ctx.ui.notify(`Blocked: ${path}`, "warning"); - return { block: true, reason: `Protected: ${path}` }; - } - }); -} -``` - -### Git Checkpoint - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: HookAPI) { - const checkpoints = new Map(); - let currentEntryId: string | undefined; - - pi.on("tool_result", async (_event, ctx) => { - const leaf = ctx.sessionManager.getLeafEntry(); - if (leaf) currentEntryId = leaf.id; - }); - - pi.on("turn_start", async () => { - const { stdout } = await pi.exec("git", ["stash", "create"]); - if (stdout.trim() && currentEntryId) { - checkpoints.set(currentEntryId, stdout.trim()); - } - }); - - pi.on("session_before_branch", async (event, ctx) => { - const ref = checkpoints.get(event.entryId); - if (!ref || !ctx.hasUI) return; - - const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?"); - if (ok) { - await pi.exec("git", ["stash", "apply", ref]); - ctx.ui.notify("Code restored", "info"); - } - }); - - pi.on("agent_end", () => checkpoints.clear()); -} -``` - -### Custom Command - -See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence. - -## Mode Behavior - -| Mode | UI Methods | Notes | -|------|-----------|-------| -| Interactive | Full TUI | Normal operation | -| RPC | JSON protocol | Host handles UI | -| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | - -In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`. - -## Error Handling - -- Hook errors are logged, agent continues -- `tool_call` errors block the tool (fail-safe) -- Errors display in UI with hook path and message -- If a hook hangs, use Ctrl+C to abort - -## Debugging - -1. Open VS Code in hooks directory -2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal") -3. Set breakpoints -4. Run `pi --hook ./my-hook.ts` diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index e485343a..ea2ae1ff 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -52,9 +52,9 @@ With images: If the agent is streaming and no `streamingBehavior` is specified, the command returns an error. -**Hook commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Hook commands manage their own LLM interaction via `pi.sendMessage()`. +**Extension commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`. -**Slash commands**: File-based slash commands (from `.md` files) are expanded before sending/queueing. +**Prompt templates**: File-based prompt templates (from `.md` files) are expanded before sending/queueing. Response: ```json @@ -65,7 +65,7 @@ The `images` field is optional. Each image uses `ImageContent` format with base6 #### steer -Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead). +Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). ```json {"type": "steer", "message": "Stop and do this instead"} @@ -80,7 +80,7 @@ See [set_steering_mode](#set_steering_mode) for controlling how steering message #### follow_up -Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based slash commands are expanded. Hook commands are not allowed (use `prompt` instead). +Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). ```json {"type": "follow_up", "message": "After you're done, also do this"} diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index ba0e656c..a1d604f6 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -129,7 +129,7 @@ interface AgentSession { ### Prompting and Message Queueing -The `prompt()` method handles slash commands, hook commands, and message sending: +The `prompt()` method handles prompt templates, extension commands, and message sending: ```typescript // Basic prompt (when not streaming) @@ -146,8 +146,8 @@ await session.prompt("After you're done, also check X", { streamingBehavior: "fo ``` **Behavior:** -- **Hook commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`. -- **File-based slash commands** (from `.md` files): Expanded to their content before sending/queueing. +- **Extension commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`. +- **File-based prompt templates** (from `.md` files): Expanded to their content before sending/queueing. - **During streaming without `streamingBehavior`**: Throws an error. Use `steer()` or `followUp()` directly, or specify the option. For explicit queueing during streaming: @@ -160,7 +160,7 @@ await session.steer("New instruction"); await session.followUp("After you're done, also do this"); ``` -Both `steer()` and `followUp()` expand file-based slash commands but error on hook commands (hook commands cannot be queued). +Both `steer()` and `followUp()` expand file-based prompt templates but error on extension commands (extension commands cannot be queued). ### Agent and AgentState @@ -260,18 +260,16 @@ const { session } = await createAgentSession({ ``` `cwd` is used for: -- Project hooks (`.pi/hooks/`) -- Project tools (`.pi/tools/`) +- Project extensions (`.pi/extensions/`) - Project skills (`.pi/skills/`) -- Project commands (`.pi/commands/`) +- Project prompts (`.pi/prompts/`) - Context files (`AGENTS.md` walking up from cwd) - Session directory naming `agentDir` is used for: -- Global hooks (`hooks/`) -- Global tools (`tools/`) +- Global extensions (`extensions/`) - Global skills (`skills/`) -- Global commands (`commands/`) +- Global prompts (`prompts/`) - Global context file (`AGENTS.md`) - Settings (`settings.json`) - Custom models (`models.json`) @@ -502,7 +500,7 @@ const loggingHook: HookFactory = (api) => { return undefined; }); - // Register custom slash command + // Register custom prompt template api.registerCommand("stats", { description: "Show session stats", handler: async (ctx) => { @@ -556,7 +554,7 @@ Hook API methods: - `api.events.on(channel, handler)` - Listen on shared event bus - `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`) - `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context) -- `api.registerCommand(name, options)` - Register custom slash command +- `api.registerCommand(name, options)` - Register custom command - `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering - `api.exec(command, args, options?)` - Execute shell commands @@ -628,11 +626,11 @@ const { session } = await createAgentSession({ ### Slash Commands ```typescript -import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent"; +import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent"; -const discovered = discoverSlashCommands(); +const discovered = discoverPromptTemplates(); -const customCommand: FileSlashCommand = { +const customCommand: PromptTemplate = { name: "deploy", description: "Deploy the application", source: "(custom)", @@ -640,11 +638,11 @@ const customCommand: FileSlashCommand = { }; const { session } = await createAgentSession({ - slashCommands: [...discovered, customCommand], + promptTemplates: [...discovered, customCommand], }); ``` -> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts) +> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts) ### Session Management @@ -773,7 +771,7 @@ import { discoverHooks, discoverCustomTools, discoverContextFiles, - discoverSlashCommands, + discoverPromptTemplates, loadSettings, buildSystemPrompt, } from "@mariozechner/pi-coding-agent"; @@ -800,8 +798,8 @@ const tools = await discoverCustomTools(eventBus, cwd, agentDir); // Context files const contextFiles = discoverContextFiles(cwd, agentDir); -// Slash commands -const commands = discoverSlashCommands(cwd, agentDir); +// Prompt templates +const commands = discoverPromptTemplates(cwd, agentDir); // Settings (global + project merged) const settings = loadSettings(cwd, agentDir); @@ -908,7 +906,7 @@ const { session } = await createAgentSession({ hooks: [{ factory: auditHook }], skills: [], contextFiles: [], - slashCommands: [], + promptTemplates: [], sessionManager: SessionManager.inMemory(), settingsManager, @@ -963,7 +961,7 @@ discoverSkills discoverHooks discoverCustomTools discoverContextFiles -discoverSlashCommands +discoverPromptTemplates // Event Bus (for shared hook/tool communication) createEventBus @@ -994,7 +992,7 @@ type CreateAgentSessionResult type CustomTool type HookFactory type Skill -type FileSlashCommand +type PromptTemplate type Settings type SkillsSettings type Tool diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md deleted file mode 100644 index 0a3a50ae..00000000 --- a/packages/coding-agent/docs/session-tree-plan.md +++ /dev/null @@ -1,441 +0,0 @@ -# Session Tree Implementation Plan - -Reference: [session-tree.md](./session-tree.md) - -## Phase 1: SessionManager Core ✅ - -- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase) -- [x] Add `version` field to `SessionHeader` -- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId` -- [x] Add `BranchSummaryEntry` type -- [x] Add `CustomEntry` type for hooks -- [x] Add `byId: Map` index -- [x] Add `leafId: string` tracking -- [x] Implement `getPath(fromId?)` tree traversal -- [x] Implement `getTree()` returning `SessionTreeNode[]` -- [x] Implement `getEntry(id)` lookup -- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers -- [x] Update `_buildIndex()` to populate `byId` map -- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf) -- [x] Add `appendCustomEntry(customType, data)` for hooks -- [x] Update `buildSessionContext()` to use `getPath()` traversal - -## Phase 2: Migration ✅ - -- [x] Add `CURRENT_SESSION_VERSION = 2` constant -- [x] Implement `migrateV1ToV2()` with extensible migration chain -- [x] Update `setSessionFile()` to detect version and migrate -- [x] Implement `_rewriteFile()` for post-migration persistence -- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration - -## Phase 3: Branching ✅ - -- [x] Implement `branch(id)` - switch leaf pointer -- [x] Implement `branchWithSummary(id, summary)` - create summary entry -- [x] Implement `createBranchedSession(leafId)` - extract path to new file -- [x] Update `AgentSession.branch()` to use new API - -## Phase 4: Compaction Integration ✅ - -- [x] Update `compaction.ts` to work with IDs -- [x] Update `prepareCompaction()` to return `firstKeptEntryId` -- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId` -- [x] Update `AgentSession` compaction methods -- [x] Add `firstKeptEntryId` to `before_compact` hook event - -## Phase 5: Testing ✅ - -- [x] `migration.test.ts` - v1 to v2 migration, idempotency -- [x] `build-context.test.ts` - context building with tree structure, compaction, branches -- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching -- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession -- [x] `save-entry.test.ts` - custom entry integration -- [x] Update existing compaction tests for new types - ---- - -## Remaining Work - -### Compaction Refactor - -- [x] Use `CompactionResult` type for hook return value -- [x] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data -- [x] Make `CompactionResult` generic to match -- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields -- [x] Update `before_compact` event: - - Pass `preparation: CompactionPreparation` instead of individual fields - - Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string` - - Keep: `customInstructions`, `model`, `signal` - - Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries` -- [x] Update hook example `custom-compaction.ts` to use new API -- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions -- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile` - -Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers. - -### Branch Summary Design ✅ - -Current type: -```typescript -export interface BranchSummaryEntry extends SessionEntryBase { - type: "branch_summary"; - summary: string; - fromId: string; // References the abandoned leaf - fromHook?: boolean; // Whether summary was generated by a hook - details?: unknown; // File tracking: { readFiles, modifiedFiles } -} -``` - -- [x] `fromId` field references the abandoned leaf -- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries -- [x] `details` field for file tracking -- [x] Branch summarizer implemented with structured output format -- [x] Uses serialization approach (same as compaction) to prevent model confusion -- [x] Tests for `branchWithSummary()` flow - -### Entry Labels ✅ - -- [x] Add `LabelEntry` type with `targetId` and `label` fields -- [x] Add `labelsById: Map` private field -- [x] Build labels map in `_buildIndex()` via linear scan -- [x] Add `getLabel(id)` method -- [x] Add `appendLabelChange(targetId, label)` method (undefined clears) -- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map -- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types) -- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()` -- [x] Display labels in UI (tree-selector shows labels) -- [x] `/label` command (implemented in tree-selector) - -### CustomMessageEntry - -Hook-injected messages that participate in LLM context. Unlike `CustomEntry` (for hook state only), these are sent to the model. - -```typescript -export interface CustomMessageEntry extends SessionEntryBase { - type: "custom_message"; - customType: string; // Hook identifier - content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage) - details?: T; // Hook-specific data for state reconstruction on reload - display: boolean; // Whether to display in TUI -} -``` - -Behavior: -- [x] Type definition matching plan -- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager -- [x] `buildSessionContext()` includes custom_message entries as user messages -- [x] Exported from main index -- [x] TUI rendering: - - `display: false` - hidden entirely - - `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) - - [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers - - [x] Renderer returns inner Component, TUI wraps in styled Box - -### Hook API Changes ✅ - -**Renamed:** -- `renderCustomMessage()` → `registerCustomMessageRenderer()` - -**New: `sendMessage()` ✅** - -Replaces `send()`. Always creates CustomMessageEntry, never user messages. - -```typescript -type HookMessage = Pick, 'customType' | 'content' | 'display' | 'details'>; - -sendMessage(message: HookMessage, triggerTurn?: boolean): void; -``` - -Implementation: -- Uses agent's queue mechanism with `_hookData` marker on AppMessage -- `message_end` handler routes based on marker presence -- `AgentSession.sendHookMessage()` handles three cases: - - Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end` - - Not streaming + triggerTurn: direct append + `agent.continue()` - - Not streaming + no trigger: direct append only -- TUI updates via event (streaming) or explicit rebuild (non-streaming) - -**New: `appendEntry()` ✅** - -For hook state persistence (NOT in LLM context): - -```typescript -appendEntry(customType: string, data?: unknown): void; -``` - -Calls `sessionManager.appendCustomEntry()` directly. - -**New: `registerCommand()` (types ✅, wiring TODO)** - -```typescript -// HookAPI (the `pi` object) - utilities available to all hooks: -interface HookAPI { - sendMessage(message: HookMessage, triggerTurn?: boolean): void; - appendEntry(customType: string, data?: unknown): void; - registerCommand(name: string, options: RegisteredCommand): void; - registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void; - exec(command: string, args: string[], options?: ExecOptions): Promise; -} - -// HookEventContext - passed to event handlers, has stable context: -interface HookEventContext { - ui: HookUIContext; - hasUI: boolean; - cwd: string; - sessionManager: SessionManager; - modelRegistry: ModelRegistry; -} -// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase - -// HookCommandContext - passed to command handlers: -interface HookCommandContext { - args: string; // Everything after /commandname - ui: HookUIContext; - hasUI: boolean; - cwd: string; - sessionManager: SessionManager; - modelRegistry: ModelRegistry; -} -// Note: exec and sendMessage accessed via `pi` closure - -registerCommand(name: string, options: { - description?: string; - handler: (ctx: HookCommandContext) => Promise; -}): void; -``` - -Handler return: -- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM) - -Wiring (all in AgentSession.prompt()): -- [x] Add hook commands to autocomplete in interactive-mode -- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution -- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc. -- [x] If handler returns string, use as prompt text -- [x] If handler returns undefined, return early (no LLM call) -- [x] Works for all modes (interactive, RPC, print) via shared AgentSession - -**New: `ui.custom()` ✅** - -For arbitrary hook UI with keyboard focus: - -```typescript -interface HookUIContext { - // ... existing: select, confirm, input, notify - - /** Show custom component with keyboard focus. Call done() when finished. */ - custom(component: Component, done: () => void): void; -} -``` - -See also: `CustomEntry` for storing hook state that does NOT participate in context. - -**New: `context` event ✅** - -Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively. - -```typescript -interface ContextEvent { - type: "context"; - /** Messages that will be sent to the LLM */ - messages: Message[]; -} - -interface ContextEventResult { - /** Modified messages to send instead */ - messages?: Message[]; -} - -// In HookAPI: -on(event: "context", handler: HookHandler): void; -``` - -Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330)) - -Non-destructive pruning of tool results to reduce context size: - -```typescript -export default function(pi: HookAPI) { - // Register /prune command - pi.registerCommand("prune", { - description: "Mark tool results for pruning", - handler: async (ctx) => { - // Show UI to select which tool results to prune - // Append custom entry recording pruning decisions: - // { toolResultId, strategy: "summary" | "truncate" | "remove" } - pi.appendEntry("tool-result-pruning", { ... }); - } - }); - - // Intercept context before LLM call - pi.on("context", async (event, ctx) => { - // Find all pruning entries in session - const entries = ctx.sessionManager.getEntries(); - const pruningRules = entries - .filter(e => e.type === "custom" && e.customType === "tool-result-pruning") - .map(e => e.data); - - // Apply pruning rules to messages - const prunedMessages = applyPruning(event.messages, pruningRules); - return { messages: prunedMessages }; - }); -} -``` - -Benefits: -- Original tool results stay intact in session -- Pruning is stored as custom entries, survives session reload -- Works with branching (pruning entries are part of the tree) -- Trade-off: cache busting on first submission after pruning - -### Investigate: `context` event vs `before_agent_start` ✅ - -References: -- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal -- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added) - -**Current `context` event:** -- Fires before each LLM call within the agent loop -- Receives `AgentMessage[]` (deep copy, safe to modify) -- Returns `Message[]` (inconsistent with input type) -- Modifications are transient (not persisted to session) -- No TUI visibility of what was changed -- Use case: non-destructive pruning, dynamic context manipulation - -**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`: -```typescript -interface ContextEvent { - messages: AgentMessage[]; // Input -} -interface ContextEventResult { - messages?: Message[]; // Output - different type! -} -``` - -Questions: -- [ ] Should input/output both be `Message[]` (LLM format)? -- [ ] Or both be `AgentMessage[]` with conversion happening after? -- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen? - -**Proposed `before_agent_start` event:** -- Fires once when user submits a prompt, before `agent_start` -- Allows hooks to inject additional content that gets **persisted** to session -- Injected content is visible in TUI (observability) -- Does not bust prompt cache (appended after user message, not modifying system prompt) - -**Key difference:** -| Aspect | `context` | `before_agent_start` | -|--------|-----------|---------------------| -| When | Before each LLM call | Once per user prompt | -| Persisted | No | Yes (as SystemMessage) | -| TUI visible | No | Yes (collapsible) | -| Cache impact | Can bust cache | Append-only, cache-safe | -| Use case | Transient manipulation | Persistent context injection | - -**Implementation (completed):** -- Reuses `HookMessage` type (no new message type needed) -- Handler returns `{ message: Pick }` -- Message is appended to agent state AND persisted to session before `agent.prompt()` is called -- Renders using existing `HookMessageComponent` (or custom renderer if registered) -- [ ] How does it interact with compaction? (treated like user messages?) -- [ ] Can hook return multiple messages or just one? - -**Implementation sketch:** -```typescript -interface BeforeAgentStartEvent { - type: "before_agent_start"; - userMessage: UserMessage; // The prompt user just submitted -} - -interface BeforeAgentStartResult { - /** Additional context to inject (persisted as SystemMessage) */ - inject?: { - label: string; // Shown in collapsed TUI state - content: string | (TextContent | ImageContent)[]; - }; -} -``` - -### HTML Export - -- [ ] Add collapsible sidebar showing full tree structure -- [ ] Allow selecting any node in tree to view that path -- [ ] Add "reset to session leaf" button -- [ ] Render full path (no compaction resolution needed) -- [ ] Responsive: collapse sidebar on mobile - -### UI Commands ✅ - -- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`) -- [x] `/tree` - In-session tree navigation via tree-selector component - - Shows full tree structure with labels - - Navigate between branches (moves leaf pointer) - - Shows current position - - Generates branch summaries when switching branches - -### Tree Selector Improvements ✅ - -- [x] Active line highlight using `selectedBg` theme color -- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward): - - `default`: hides label/custom entries - - `no-tools`: default minus tool results - - `user-only`: just user messages - - `labeled-only`: just labeled entries - - `all`: everything - -### Documentation - -Review and update all docs: - -- [ ] `docs/hooks.md` - Major update for hook API: - - `pi.send()` → `pi.sendMessage()` with new signature - - New `pi.appendEntry()` for state persistence - - New `pi.registerCommand()` for custom slash commands - - New `pi.registerCustomMessageRenderer()` for custom TUI rendering - - `HookCommandContext` interface and handler patterns - - `HookMessage` type - - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) -- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete -- [ ] `docs/sdk.md` - Update for: - - `HookMessage` and `isHookMessage()` - - `Agent.prompt(AppMessage)` overload - - Session v2 tree structure - - SessionManager API changes -- [ ] `docs/session.md` - Update for v2 tree structure, new entry types -- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools -- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode -- [ ] `docs/skills.md` - Review for any hook-related updates -- [ ] `docs/extension-loading.md` - Review -- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total) -- [ ] `README.md` - Update hook examples if any - -### Examples - -Review and update examples: - -- [ ] `examples/hooks/` - Update existing, add new examples: - - [ ] Review `custom-compaction.ts` for new API - - [ ] Add `registerCommand()` example - - [ ] Add `sendMessage()` example - - [ ] Add `registerCustomMessageRenderer()` example -- [ ] `examples/sdk/` - Update for new session/hook APIs -- [ ] `examples/custom-tools/` - Review for compatibility - ---- - -## Before Release - -- [ ] Run full automated test suite: `npm test` -- [ ] Manual testing of tree navigation and branch summarization -- [ ] Verify compaction with file tracking works correctly - ---- - -## Notes - -- All append methods return the new entry's ID -- Migration rewrites file on first load if version < CURRENT_VERSION -- Existing sessions become linear chains after migration (parentId = previous entry) -- Tree features available immediately after migration -- SessionHeader does NOT have id/parentId (it's metadata, not part of tree) -- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index c2685d97..58a9c731 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -37,7 +37,7 @@ Skills are loaded when: **Not a good fit for skills:** - "Always use TypeScript strict mode" → put in AGENTS.md -- "Review my code" → make a slash command +- "Review my code" → make a prompt template - Need user confirmation dialogs or custom TUI rendering → make a custom tool ## Skill Structure diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 6f0f8132..1f03d748 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -343,4 +343,4 @@ Call `invalidate()` when state changes, then `handle.requestRender()` to trigger ## Examples - **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence -- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult` +- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - Custom `renderCall` and `renderResult` diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md index 5e4937a5..cb0552c3 100644 --- a/packages/coding-agent/examples/README.md +++ b/packages/coding-agent/examples/README.md @@ -1,27 +1,21 @@ # Examples -Example code for pi-coding-agent SDK, hooks, and custom tools. +Example code for pi-coding-agent SDK and extensions. ## Directories ### [sdk/](sdk/) -Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, hooks, and session management. +Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, extensions, and session management. -### [hooks/](hooks/) -Example hooks for intercepting tool calls, adding safety gates, and integrating with external systems. - -### [custom-tools/](custom-tools/) -Example custom tools that extend the agent's capabilities. - -## Tool + Hook Combinations - -Some examples are designed to work together: - -- **todo/** - The [custom tool](custom-tools/todo/) lets the LLM manage a todo list, while the [hook](hooks/todo/) adds a `/todos` command for users to view todos at any time. +### [extensions/](extensions/) +Example extensions demonstrating: +- Lifecycle event handlers (tool interception, safety gates) +- Custom tools (todo lists, subagents) +- Commands and keyboard shortcuts +- External integrations (git, file watchers) ## Documentation - [SDK Reference](sdk/README.md) -- [Hooks Documentation](../docs/hooks.md) -- [Custom Tools Documentation](../docs/custom-tools.md) +- [Extensions Documentation](../docs/extensions.md) - [Skills Documentation](../docs/skills.md) diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md deleted file mode 100644 index b665a211..00000000 --- a/packages/coding-agent/examples/custom-tools/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Custom Tools Examples - -Example custom tools for pi-coding-agent. - -## Examples - -Each example uses the `subdirectory/index.ts` structure required for tool discovery. - -### hello/ -Minimal example showing the basic structure of a custom tool. - -### question/ -Demonstrates `pi.ui.select()` for asking the user questions with options. - -### todo/ -Full-featured example demonstrating: -- `onSession` for state reconstruction from session history -- Custom `renderCall` and `renderResult` -- Proper branching support via details storage -- State management without external files - -**Companion hook:** [hooks/todo/](../hooks/todo/) adds a `/todos` command for users to view the todo list. - -### subagent/ -Delegate tasks to specialized subagents with isolated context windows. Includes: -- `index.ts` - The custom tool (single, parallel, and chain modes) -- `agents.ts` - Agent discovery helper -- `agents/` - Sample agent definitions (scout, planner, reviewer, worker) -- `commands/` - Workflow presets (/implement, /scout-and-plan, /implement-and-review) - -See [subagent/README.md](subagent/README.md) for full documentation. - -## Usage - -```bash -# Test directly (can point to any .ts file) -pi --tool examples/custom-tools/todo/index.ts - -# Or copy entire folder to tools directory for persistent use -cp -r todo ~/.pi/agent/tools/ -``` - -Then in pi: -``` -> add a todo "test custom tools" -> list todos -> toggle todo #1 -> clear todos -``` - -## Writing Custom Tools - -See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation. - -### Key Points - -**Factory pattern:** -```typescript -import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { Text } from "@mariozechner/pi-tui"; -import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; - -const factory: CustomToolFactory = (pi) => ({ - name: "my_tool", - label: "My Tool", - description: "Tool description for LLM", - parameters: Type.Object({ - action: StringEnum(["list", "add"] as const), - }), - - // Called on session start/switch/branch/clear - onSession(event) { - // Reconstruct state from event.entries - }, - - async execute(toolCallId, params) { - return { - content: [{ type: "text", text: "Result" }], - details: { /* for rendering and state reconstruction */ }, - }; - }, -}); - -export default factory; -``` - -**Custom rendering:** -```typescript -renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("my_tool ")) + args.action, - 0, 0 // No padding - Box handles it - ); -}, - -renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) { - return new Text(theme.fg("warning", "Working..."), 0, 0); - } - return new Text(theme.fg("success", "✓ Done"), 0, 0); -}, -``` - -**Use StringEnum for string parameters** (required for Google API compatibility): -```typescript -import { StringEnum } from "@mariozechner/pi-ai"; - -// Good -action: StringEnum(["list", "add"] as const) - -// Bad - doesn't work with Google -action: Type.Union([Type.Literal("list"), Type.Literal("add")]) -``` diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts deleted file mode 100644 index e72e7f05..00000000 --- a/packages/coding-agent/examples/custom-tools/hello/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; - -const factory: CustomToolFactory = (_pi) => ({ - name: "hello", - label: "Hello", - description: "A simple greeting tool", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), - }), - - async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - const { name } = params as { name: string }; - return { - content: [{ type: "text", text: `Hello, ${name}!` }], - details: { greeted: name }, - }; - }, -}); - -export default factory; diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md new file mode 100644 index 00000000..92f3fe72 --- /dev/null +++ b/packages/coding-agent/examples/extensions/README.md @@ -0,0 +1,141 @@ +# Extension Examples + +Example extensions for pi-coding-agent. + +## Usage + +```bash +# Load an extension with --extension flag +pi --extension examples/extensions/permission-gate.ts + +# Or copy to extensions directory for auto-discovery +cp permission-gate.ts ~/.pi/agent/extensions/ +``` + +## Examples + +### Lifecycle & Safety + +| Extension | Description | +|-----------|-------------| +| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | +| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | +| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | + +### Custom Tools + +| Extension | Description | +|-----------|-------------| +| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence | +| `hello/` | Minimal custom tool example | +| `question/` | Demonstrates `pi.ui.select()` for asking the user questions | +| `subagent/` | Delegate tasks to specialized subagents with isolated context windows | + +### Commands & UI + +| Extension | Description | +|-----------|-------------| +| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command | +| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence | +| `handoff.ts` | Transfer context to a new focused session via `/handoff ` | +| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | +| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | +| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | + +### Git Integration + +| Extension | Description | +|-----------|-------------| +| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | +| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | + +### System Prompt & Compaction + +| Extension | Description | +|-----------|-------------| +| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt | +| `custom-compaction.ts` | Custom compaction that summarizes entire conversation | + +### External Dependencies + +| Extension | Description | +|-----------|-------------| +| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) | +| `with-deps/` | Extension with its own package.json and dependencies | +| `file-trigger.ts` | Watches a trigger file and injects contents into conversation | + +## Writing Extensions + +See [docs/extensions.md](../../docs/extensions.md) for full documentation. + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + // Subscribe to lifecycle events + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register custom tools + pi.registerTool({ + name: "greet", + label: "Greeting", + description: "Generate a greeting", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, onUpdate, ctx, signal) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register commands + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify("Hello!", "info"); + }, + }); +} +``` + +## Key Patterns + +**Use StringEnum for string parameters** (required for Google API compatibility): +```typescript +import { StringEnum } from "@mariozechner/pi-ai"; + +// Good +action: StringEnum(["list", "add"] as const) + +// Bad - doesn't work with Google +action: Type.Union([Type.Literal("list"), Type.Literal("add")]) +``` + +**State persistence via details:** +```typescript +// Store state in tool result details for proper branching support +return { + content: [{ type: "text", text: "Done" }], + details: { todos: [...todos], nextId }, // Persisted in session +}; + +// Reconstruct on session events +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.toolName === "my_tool") { + const details = entry.message.details; + // Reconstruct state from details + } + } +}); +``` diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/extensions/auto-commit-on-exit.ts similarity index 90% rename from packages/coding-agent/examples/hooks/auto-commit-on-exit.ts rename to packages/coding-agent/examples/extensions/auto-commit-on-exit.ts index 598ecdc2..f82ef57c 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/extensions/auto-commit-on-exit.ts @@ -1,13 +1,13 @@ /** - * Auto-Commit on Exit Hook + * Auto-Commit on Exit Extension * * Automatically commits changes when the agent exits. * Uses the last assistant message to generate a commit message. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.on("session_shutdown", async (_event, ctx) => { // Check for uncommitted changes const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]); diff --git a/packages/coding-agent/examples/extensions/chalk-logger.ts b/packages/coding-agent/examples/extensions/chalk-logger.ts new file mode 100644 index 00000000..b1b52de1 --- /dev/null +++ b/packages/coding-agent/examples/extensions/chalk-logger.ts @@ -0,0 +1,26 @@ +/** + * Example extension that uses a 3rd party dependency (chalk). + * Tests that jiti can resolve npm modules correctly. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import chalk from "chalk"; + +export default function (pi: ExtensionAPI) { + // Log with colors using chalk + console.log(`${chalk.green("✓")} ${chalk.bold("chalk-logger extension loaded")}`); + + pi.on("agent_start", async () => { + console.log(`${chalk.blue("[chalk-logger]")} Agent starting`); + }); + + pi.on("tool_call", async (event) => { + console.log(`${chalk.yellow("[chalk-logger]")} Tool: ${chalk.cyan(event.toolName)}`); + return undefined; + }); + + pi.on("agent_end", async (event) => { + const count = event.messages.length; + console.log(`${chalk.green("[chalk-logger]")} Done with ${chalk.bold(String(count))} messages`); + }); +} diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/extensions/confirm-destructive.ts similarity index 88% rename from packages/coding-agent/examples/hooks/confirm-destructive.ts rename to packages/coding-agent/examples/extensions/confirm-destructive.ts index ff213929..66157113 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/extensions/confirm-destructive.ts @@ -1,13 +1,13 @@ /** - * Confirm Destructive Actions Hook + * Confirm Destructive Actions Extension * * Prompts for confirmation before destructive session actions (clear, switch, branch). * Demonstrates how to cancel session events using the before_* events. */ -import type { HookAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => { if (!ctx.hasUI) return; diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/extensions/custom-compaction.ts similarity index 92% rename from packages/coding-agent/examples/hooks/custom-compaction.ts rename to packages/coding-agent/examples/extensions/custom-compaction.ts index 5f413e03..de2500bb 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/extensions/custom-compaction.ts @@ -1,8 +1,8 @@ /** - * Custom Compaction Hook + * Custom Compaction Extension * * Replaces the default compaction behavior with a full summary of the entire context. - * Instead of keeping the last 20k tokens of conversation turns, this hook: + * Instead of keeping the last 20k tokens of conversation turns, this extension: * 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages) * 2. Discards all old turns completely, keeping only the summary * @@ -10,16 +10,16 @@ * which can be cheaper/faster than the main conversation model. * * Usage: - * pi --hook examples/hooks/custom-compaction.ts + * pi --extension examples/extensions/custom-compaction.ts */ import { complete, getModel } from "@mariozechner/pi-ai"; -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.on("session_before_compact", async (event, ctx) => { - ctx.ui.notify("Custom compaction hook triggered", "info"); + ctx.ui.notify("Custom compaction extension triggered", "info"); const { preparation, branchEntries: _, signal } = event; const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation; diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/extensions/dirty-repo-guard.ts similarity index 79% rename from packages/coding-agent/examples/hooks/dirty-repo-guard.ts rename to packages/coding-agent/examples/extensions/dirty-repo-guard.ts index 49335c72..6f259196 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/extensions/dirty-repo-guard.ts @@ -1,13 +1,17 @@ /** - * Dirty Repo Guard Hook + * Dirty Repo Guard Extension * * Prevents session changes when there are uncommitted git changes. * Useful to ensure work is committed before switching context. */ -import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> { +async function checkDirtyRepo( + pi: ExtensionAPI, + ctx: ExtensionContext, + action: string, +): Promise<{ cancel: boolean } | undefined> { // Check for uncommitted changes const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); @@ -40,7 +44,7 @@ async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Pr } } -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.on("session_before_switch", async (event, ctx) => { const action = event.reason === "new" ? "new session" : "switch session"; return checkDirtyRepo(pi, ctx, action); diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/extensions/file-trigger.ts similarity index 86% rename from packages/coding-agent/examples/hooks/file-trigger.ts rename to packages/coding-agent/examples/extensions/file-trigger.ts index 08c995ad..76abcfeb 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/extensions/file-trigger.ts @@ -1,5 +1,5 @@ /** - * File Trigger Hook + * File Trigger Extension * * Watches a trigger file and injects its contents into the conversation. * Useful for external systems to send messages to the agent. @@ -9,9 +9,9 @@ */ import * as fs from "node:fs"; -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const triggerFile = "/tmp/agent-trigger.txt"; diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/extensions/git-checkpoint.ts similarity index 90% rename from packages/coding-agent/examples/hooks/git-checkpoint.ts rename to packages/coding-agent/examples/extensions/git-checkpoint.ts index 1ea89449..7d0414bc 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/extensions/git-checkpoint.ts @@ -1,13 +1,13 @@ /** - * Git Checkpoint Hook + * Git Checkpoint Extension * * Creates git stash checkpoints at each turn so /branch can restore code state. * When branching, offers to restore code to that point in history. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { const checkpoints = new Map(); let currentEntryId: string | undefined; diff --git a/packages/coding-agent/examples/hooks/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts similarity index 95% rename from packages/coding-agent/examples/hooks/handoff.ts rename to packages/coding-agent/examples/extensions/handoff.ts index 8817947f..09c7227c 100644 --- a/packages/coding-agent/examples/hooks/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -1,5 +1,5 @@ /** - * Handoff hook - transfer context to a new focused session + * Handoff extension - transfer context to a new focused session * * Instead of compacting (which is lossy), handoff extracts what matters * for your next task and creates a new session with a generated prompt. @@ -13,7 +13,7 @@ */ import { complete, type Message } from "@mariozechner/pi-ai"; -import type { HookAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that: @@ -38,7 +38,7 @@ Files involved: ## Task [Clear description of what to do next based on user's goal]`; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.registerCommand("handoff", { description: "Transfer context to a new focused session", handler: async (args, ctx) => { diff --git a/packages/coding-agent/examples/extensions/hello/index.ts b/packages/coding-agent/examples/extensions/hello/index.ts new file mode 100644 index 00000000..1ae6f57e --- /dev/null +++ b/packages/coding-agent/examples/extensions/hello/index.ts @@ -0,0 +1,21 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "hello", + label: "Hello", + description: "A simple greeting tool", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + const { name } = params as { name: string }; + return { + content: [{ type: "text", text: `Hello, ${name}!` }], + details: { greeted: name }, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/hooks/permission-gate.ts b/packages/coding-agent/examples/extensions/permission-gate.ts similarity index 86% rename from packages/coding-agent/examples/hooks/permission-gate.ts rename to packages/coding-agent/examples/extensions/permission-gate.ts index c3619fd0..0fc97c4d 100644 --- a/packages/coding-agent/examples/hooks/permission-gate.ts +++ b/packages/coding-agent/examples/extensions/permission-gate.ts @@ -1,13 +1,13 @@ /** - * Permission Gate Hook + * Permission Gate Extension * * Prompts for confirmation before running potentially dangerous bash commands. * Patterns checked: rm -rf, sudo, chmod/chown 777 */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; pi.on("tool_call", async (event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/pirate.ts b/packages/coding-agent/examples/extensions/pirate.ts similarity index 80% rename from packages/coding-agent/examples/hooks/pirate.ts rename to packages/coding-agent/examples/extensions/pirate.ts index 2f9c854c..559960f0 100644 --- a/packages/coding-agent/examples/hooks/pirate.ts +++ b/packages/coding-agent/examples/extensions/pirate.ts @@ -1,18 +1,18 @@ /** - * Pirate Hook + * Pirate Extension * * Demonstrates using systemPromptAppend in before_agent_start to dynamically - * modify the system prompt based on hook state. + * modify the system prompt based on extension state. * * Usage: - * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ + * 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/ * 2. Use /pirate to toggle pirate mode * 3. When enabled, the agent will respond like a pirate */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function pirateHook(pi: HookAPI) { +export default function pirateExtension(pi: ExtensionAPI) { let pirateMode = false; // Register /pirate command to toggle pirate mode diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/extensions/plan-mode.ts similarity index 97% rename from packages/coding-agent/examples/hooks/plan-mode.ts rename to packages/coding-agent/examples/extensions/plan-mode.ts index 56a2ea35..8f3efdf6 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/extensions/plan-mode.ts @@ -1,5 +1,5 @@ /** - * Plan Mode Hook + * Plan Mode Extension * * Provides a Claude Code-style "plan mode" for safe code exploration. * When enabled, the agent can only use read-only tools and cannot modify files. @@ -14,12 +14,12 @@ * - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete * * Usage: - * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ + * 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/ * 2. Use /plan to toggle plan mode on/off * 3. Or start in plan mode with --plan flag */ -import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Key } from "@mariozechner/pi-tui"; // Read-only tools for plan mode @@ -207,7 +207,7 @@ function extractTodoItems(message: string): TodoItem[] { return items; } -export default function planModeHook(pi: HookAPI) { +export default function planModeExtension(pi: ExtensionAPI) { let planModeEnabled = false; let toolsCalledThisTurn = false; let executionMode = false; @@ -221,7 +221,7 @@ export default function planModeHook(pi: HookAPI) { }); // Helper to update status displays - function updateStatus(ctx: HookContext) { + function updateStatus(ctx: ExtensionContext) { if (executionMode && todoItems.length > 0) { const completed = todoItems.filter((t) => t.completed).length; ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); @@ -249,7 +249,7 @@ export default function planModeHook(pi: HookAPI) { } } - function togglePlanMode(ctx: HookContext) { + function togglePlanMode(ctx: ExtensionContext) { planModeEnabled = !planModeEnabled; executionMode = false; todoItems = []; diff --git a/packages/coding-agent/examples/hooks/protected-paths.ts b/packages/coding-agent/examples/extensions/protected-paths.ts similarity index 82% rename from packages/coding-agent/examples/hooks/protected-paths.ts rename to packages/coding-agent/examples/extensions/protected-paths.ts index 8431d2fb..fbc1169c 100644 --- a/packages/coding-agent/examples/hooks/protected-paths.ts +++ b/packages/coding-agent/examples/extensions/protected-paths.ts @@ -1,13 +1,13 @@ /** - * Protected Paths Hook + * Protected Paths Extension * * Blocks write and edit operations to protected paths. * Useful for preventing accidental modifications to sensitive files. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; pi.on("tool_call", async (event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/qna.ts b/packages/coding-agent/examples/extensions/qna.ts similarity index 94% rename from packages/coding-agent/examples/hooks/qna.ts rename to packages/coding-agent/examples/extensions/qna.ts index 92bb14d7..39ae902d 100644 --- a/packages/coding-agent/examples/hooks/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -1,5 +1,5 @@ /** - * Q&A extraction hook - extracts questions from assistant responses + * Q&A extraction extension - extracts questions from assistant responses * * Demonstrates the "prompt generator" pattern: * 1. /qna command gets the last assistant message @@ -8,7 +8,7 @@ */ import { complete, type UserMessage } from "@mariozechner/pi-ai"; -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { BorderedLoader } from "@mariozechner/pi-coding-agent"; const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in. @@ -27,7 +27,7 @@ A: Keep questions in the order they appeared. Be concise.`; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.registerCommand("qna", { description: "Extract questions from last assistant message into editor", handler: async (_args, ctx) => { diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/extensions/question/index.ts similarity index 80% rename from packages/coding-agent/examples/custom-tools/question/index.ts rename to packages/coding-agent/examples/extensions/question/index.ts index e75e8c45..eb6694df 100644 --- a/packages/coding-agent/examples/custom-tools/question/index.ts +++ b/packages/coding-agent/examples/extensions/question/index.ts @@ -2,7 +2,7 @@ * Question Tool - Let the LLM ask the user a question with options */ -import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; @@ -17,40 +17,40 @@ const QuestionParams = Type.Object({ options: Type.Array(Type.String(), { description: "Options for the user to choose from" }), }); -const factory: CustomToolFactory = (pi) => { - const tool: CustomTool = { +export default function (pi: ExtensionAPI) { + pi.registerTool({ name: "question", label: "Question", description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", parameters: QuestionParams, - async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - if (!pi.hasUI) { + async execute(_toolCallId, params, _onUpdate, ctx, _signal) { + if (!ctx.hasUI) { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], - details: { question: params.question, options: params.options, answer: null }, + details: { question: params.question, options: params.options, answer: null } as QuestionDetails, }; } if (params.options.length === 0) { return { content: [{ type: "text", text: "Error: No options provided" }], - details: { question: params.question, options: [], answer: null }, + details: { question: params.question, options: [], answer: null } as QuestionDetails, }; } - const answer = await pi.ui.select(params.question, params.options); + const answer = await ctx.ui.select(params.question, params.options); if (answer === undefined) { return { content: [{ type: "text", text: "User cancelled the selection" }], - details: { question: params.question, options: params.options, answer: null }, + details: { question: params.question, options: params.options, answer: null } as QuestionDetails, }; } return { content: [{ type: "text", text: `User selected: ${answer}` }], - details: { question: params.question, options: params.options, answer }, + details: { question: params.question, options: params.options, answer } as QuestionDetails, }; }, @@ -63,7 +63,7 @@ const factory: CustomToolFactory = (pi) => { }, renderResult(result, _options, theme) { - const { details } = result; + const details = result.details as QuestionDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); @@ -75,9 +75,5 @@ const factory: CustomToolFactory = (pi) => { return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0); }, - }; - - return tool; -}; - -export default factory; + }); +} diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/extensions/snake.ts similarity index 98% rename from packages/coding-agent/examples/hooks/snake.ts rename to packages/coding-agent/examples/extensions/snake.ts index a186f1b7..7f0d3cdc 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/extensions/snake.ts @@ -1,8 +1,8 @@ /** - * Snake game hook - play snake with /snake command + * Snake game extension - play snake with /snake command */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { matchesKey, visibleWidth } from "@mariozechner/pi-tui"; const GAME_WIDTH = 40; @@ -306,7 +306,7 @@ class SnakeComponent { const SNAKE_SAVE_TYPE = "snake-save"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { pi.registerCommand("snake", { description: "Play Snake!", diff --git a/packages/coding-agent/examples/hooks/status-line.ts b/packages/coding-agent/examples/extensions/status-line.ts similarity index 87% rename from packages/coding-agent/examples/hooks/status-line.ts rename to packages/coding-agent/examples/extensions/status-line.ts index 00000062..3c5f7786 100644 --- a/packages/coding-agent/examples/hooks/status-line.ts +++ b/packages/coding-agent/examples/extensions/status-line.ts @@ -1,13 +1,13 @@ /** - * Status Line Hook + * Status Line Extension * * Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer. * Shows turn progress with themed colors. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -export default function (pi: HookAPI) { +export default function (pi: ExtensionAPI) { let turnCount = 0; pi.on("session_start", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/custom-tools/subagent/README.md b/packages/coding-agent/examples/extensions/subagent/README.md similarity index 85% rename from packages/coding-agent/examples/custom-tools/subagent/README.md rename to packages/coding-agent/examples/extensions/subagent/README.md index 84ba282b..8599679f 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/README.md +++ b/packages/coding-agent/examples/extensions/subagent/README.md @@ -16,14 +16,14 @@ Delegate tasks to specialized subagents with isolated context windows. ``` subagent/ ├── README.md # This file -├── index.ts # The custom tool (entry point) +├── index.ts # The extension (entry point) ├── agents.ts # Agent discovery logic ├── agents/ # Sample agent definitions │ ├── scout.md # Fast recon, returns compressed context │ ├── planner.md # Creates implementation plans │ ├── reviewer.md # Code review │ └── worker.md # General-purpose (full capabilities) -└── commands/ # Workflow presets +└── prompts/ # Workflow presets (prompt templates) ├── implement.md # scout -> planner -> worker ├── scout-and-plan.md # scout -> planner (no implementation) └── implement-and-review.md # worker -> reviewer -> worker @@ -34,21 +34,21 @@ subagent/ From the repository root, symlink the files: ```bash -# Symlink the tool (must be in a subdirectory with index.ts) -mkdir -p ~/.pi/agent/tools/subagent -ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/index.ts" ~/.pi/agent/tools/subagent/index.ts -ln -sf "$(pwd)/packages/coding-agent/examples/custom-tools/subagent/agents.ts" ~/.pi/agent/tools/subagent/agents.ts +# Symlink the extension (must be in a subdirectory with index.ts) +mkdir -p ~/.pi/agent/extensions/subagent +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts # Symlink agents mkdir -p ~/.pi/agent/agents -for f in packages/coding-agent/examples/custom-tools/subagent/agents/*.md; do +for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f") done -# Symlink workflow commands -mkdir -p ~/.pi/agent/commands -for f in packages/coding-agent/examples/custom-tools/subagent/commands/*.md; do - ln -sf "$(pwd)/$f" ~/.pi/agent/commands/$(basename "$f") +# Symlink workflow prompts +mkdir -p ~/.pi/agent/prompts +for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do + ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f") done ``` @@ -81,7 +81,7 @@ Run 2 scouts in parallel: one to find models, one to find providers Use a chain: first have scout find the read tool, then have planner suggest improvements ``` -### Workflow commands +### Workflow prompts ``` /implement add Redis caching to the session store /scout-and-plan refactor auth to support OAuth @@ -150,10 +150,10 @@ Project agents override user agents with the same name when `agentScope: "both"` | `reviewer` | Code review | Sonnet | read, grep, find, ls, bash | | `worker` | General-purpose | Sonnet | (all default) | -## Workflow Commands +## Workflow Prompts -| Command | Flow | -|---------|------| +| Prompt | Flow | +|--------|------| | `/implement ` | scout → planner → worker | | `/scout-and-plan ` | scout → planner | | `/implement-and-review ` | worker → reviewer → worker | diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/agents.ts rename to packages/coding-agent/examples/extensions/subagent/agents.ts diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md b/packages/coding-agent/examples/extensions/subagent/agents/planner.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/agents/planner.md rename to packages/coding-agent/examples/extensions/subagent/agents/planner.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md b/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md rename to packages/coding-agent/examples/extensions/subagent/agents/reviewer.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md b/packages/coding-agent/examples/extensions/subagent/agents/scout.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/agents/scout.md rename to packages/coding-agent/examples/extensions/subagent/agents/scout.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md b/packages/coding-agent/examples/extensions/subagent/agents/worker.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/agents/worker.md rename to packages/coding-agent/examples/extensions/subagent/agents/worker.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts similarity index 94% rename from packages/coding-agent/examples/custom-tools/subagent/index.ts rename to packages/coding-agent/examples/extensions/subagent/index.ts index 3361e6d6..5bd41124 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -19,19 +19,13 @@ import * as path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Message } from "@mariozechner/pi-ai"; import { StringEnum } from "@mariozechner/pi-ai"; -import { - type CustomTool, - type CustomToolAPI, - type CustomToolFactory, - getMarkdownTheme, -} from "@mariozechner/pi-coding-agent"; +import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; -import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js"; +import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; -const MAX_AGENTS_IN_DESCRIPTION = 10; const COLLAPSED_ITEM_COUNT = 10; function formatTokens(count: number): string { @@ -224,7 +218,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string type OnUpdateCallback = (partial: AgentToolResult) => void; async function runSingleAgent( - pi: CustomToolAPI, + defaultCwd: string, agents: AgentConfig[], agentName: string, task: string, @@ -289,7 +283,7 @@ async function runSingleAgent( let wasAborted = false; const exitCode = await new Promise((resolve) => { - const proc = spawn("pi", args, { cwd: cwd ?? pi.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); let buffer = ""; const processLine = (line: string) => { @@ -410,32 +404,21 @@ const SubagentParams = Type.Object({ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), }); -const factory: CustomToolFactory = (pi) => { - const tool: CustomTool = { +export default function (pi: ExtensionAPI) { + pi.registerTool({ name: "subagent", label: "Subagent", - get description() { - const user = discoverAgents(pi.cwd, "user"); - const project = discoverAgents(pi.cwd, "project"); - const userList = formatAgentList(user.agents, MAX_AGENTS_IN_DESCRIPTION); - const projectList = formatAgentList(project.agents, MAX_AGENTS_IN_DESCRIPTION); - const userSuffix = userList.remaining > 0 ? `; ... and ${userList.remaining} more` : ""; - const projectSuffix = projectList.remaining > 0 ? `; ... and ${projectList.remaining} more` : ""; - const projectDirNote = project.projectAgentsDir ? ` (from ${project.projectAgentsDir})` : ""; - return [ - "Delegate tasks to specialized subagents with isolated context.", - "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", - 'Default agent scope is "user" (from ~/.pi/agent/agents).', - 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', - `User agents: ${userList.text}${userSuffix}.`, - `Project agents${projectDirNote}: ${projectList.text}${projectSuffix}.`, - ].join(" "); - }, + description: [ + "Delegate tasks to specialized subagents with isolated context.", + "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", + 'Default agent scope is "user" (from ~/.pi/agent/agents).', + 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + ].join(" "), parameters: SubagentParams, - async execute(_toolCallId, params, onUpdate, _ctx, signal) { + async execute(_toolCallId, params, onUpdate, ctx, signal) { const agentScope: AgentScope = params.agentScope ?? "user"; - const discovery = discoverAgents(pi.cwd, agentScope); + const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; @@ -466,7 +449,7 @@ const factory: CustomToolFactory = (pi) => { }; } - if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && pi.hasUI) { + if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { const requestedAgentNames = new Set(); if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); @@ -479,7 +462,7 @@ const factory: CustomToolFactory = (pi) => { if (projectAgentsRequested.length > 0) { const names = projectAgentsRequested.map((a) => a.name).join(", "); const dir = discovery.projectAgentsDir ?? "(unknown)"; - const ok = await pi.ui.confirm( + const ok = await ctx.ui.confirm( "Run project-local agents?", `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, ); @@ -515,7 +498,7 @@ const factory: CustomToolFactory = (pi) => { : undefined; const result = await runSingleAgent( - pi, + ctx.cwd, agents, step.agent, taskWithContext, @@ -589,7 +572,7 @@ const factory: CustomToolFactory = (pi) => { const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const result = await runSingleAgent( - pi, + ctx.cwd, agents, t.agent, t.task, @@ -629,7 +612,7 @@ const factory: CustomToolFactory = (pi) => { if (params.agent && params.task) { const result = await runSingleAgent( - pi, + ctx.cwd, agents, params.agent, params.task, @@ -707,7 +690,7 @@ const factory: CustomToolFactory = (pi) => { }, renderResult(result, { expanded }, theme) { - const { details } = result; + const details = result.details as SubagentDetails | undefined; if (!details || details.results.length === 0) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); @@ -976,9 +959,5 @@ const factory: CustomToolFactory = (pi) => { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); }, - }; - - return tool; -}; - -export default factory; + }); +} diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md b/packages/coding-agent/examples/extensions/subagent/prompts/implement-and-review.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md rename to packages/coding-agent/examples/extensions/subagent/prompts/implement-and-review.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md b/packages/coding-agent/examples/extensions/subagent/prompts/implement.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/commands/implement.md rename to packages/coding-agent/examples/extensions/subagent/prompts/implement.md diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md b/packages/coding-agent/examples/extensions/subagent/prompts/scout-and-plan.md similarity index 100% rename from packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md rename to packages/coding-agent/examples/extensions/subagent/prompts/scout-and-plan.md diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/extensions/todo.ts similarity index 53% rename from packages/coding-agent/examples/custom-tools/todo/index.ts rename to packages/coding-agent/examples/extensions/todo.ts index a20bf3de..8b85582e 100644 --- a/packages/coding-agent/examples/custom-tools/todo/index.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -1,21 +1,18 @@ /** - * Todo Tool - Demonstrates state management via session entries + * Todo Extension - Demonstrates state management via session entries * - * This tool stores state in tool result details (not external files), - * which allows proper branching - when you branch, the todo state - * is automatically correct for that point in history. + * This extension: + * - Registers a `todo` tool for the LLM to manage todos + * - Registers a `/todos` command for users to view the list * - * The onSession callback reconstructs state by scanning past tool results. + * State is stored in tool result details (not external files), which allows + * proper branching - when you branch, the todo state is automatically + * correct for that point in history. */ import { StringEnum } from "@mariozechner/pi-ai"; -import type { - CustomTool, - CustomToolContext, - CustomToolFactory, - CustomToolSessionEvent, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; interface Todo { @@ -24,7 +21,6 @@ interface Todo { done: boolean; } -// State stored in tool result details interface TodoDetails { action: "list" | "add" | "toggle" | "clear"; todos: Todo[]; @@ -32,14 +28,81 @@ interface TodoDetails { error?: string; } -// Define schema separately for proper type inference const TodoParams = Type.Object({ action: StringEnum(["list", "add", "toggle", "clear"] as const), text: Type.Optional(Type.String({ description: "Todo text (for add)" })), id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), }); -const factory: CustomToolFactory = (_pi) => { +/** + * UI component for the /todos command + */ +class TodoListComponent { + private todos: Todo[]; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(todos: Todo[], theme: Theme, onClose: () => void) { + this.todos = todos; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const th = this.theme; + + lines.push(""); + const title = th.fg("accent", " Todos "); + const headerLine = + th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); + lines.push(truncateToWidth(headerLine, width)); + lines.push(""); + + if (this.todos.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); + } else { + const done = this.todos.filter((t) => t.done).length; + const total = this.todos.length; + lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width)); + lines.push(""); + + for (const todo of this.todos) { + const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); + const id = th.fg("accent", `#${todo.id}`); + const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); + lines.push(truncateToWidth(` ${check} ${id} ${text}`, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +export default function (pi: ExtensionAPI) { // In-memory state (reconstructed from session on load) let todos: Todo[] = []; let nextId = 1; @@ -48,18 +111,14 @@ const factory: CustomToolFactory = (_pi) => { * Reconstruct state from session entries. * Scans tool results for this tool and applies them in order. */ - const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => { + const reconstructState = (ctx: ExtensionContext) => { todos = []; nextId = 1; - // Use getBranch() to get entries on the current branch for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; - - // Tool results have role "toolResult" - if (msg.role !== "toolResult") continue; - if (msg.toolName !== "todo") continue; + if (msg.role !== "toolResult" || msg.toolName !== "todo") continue; const details = msg.details as TodoDetails | undefined; if (details) { @@ -69,15 +128,19 @@ const factory: CustomToolFactory = (_pi) => { } }; - const tool: CustomTool = { + // Reconstruct state on session events + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_branch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // Register the todo tool for the LLM + pi.registerTool({ name: "todo", label: "Todo", description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", parameters: TodoParams, - // Called on session start/switch/branch/clear - onSession: reconstructState, - async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { switch (params.action) { case "list": @@ -90,21 +153,21 @@ const factory: CustomToolFactory = (_pi) => { : "No todos", }, ], - details: { action: "list", todos: [...todos], nextId }, + details: { action: "list", todos: [...todos], nextId } as TodoDetails, }; case "add": { if (!params.text) { return { content: [{ type: "text", text: "Error: text required for add" }], - details: { action: "add", todos: [...todos], nextId, error: "text required" }, + details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, }; } const newTodo: Todo = { id: nextId++, text: params.text, done: false }; todos.push(newTodo); return { content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], - details: { action: "add", todos: [...todos], nextId }, + details: { action: "add", todos: [...todos], nextId } as TodoDetails, }; } @@ -112,20 +175,25 @@ const factory: CustomToolFactory = (_pi) => { if (params.id === undefined) { return { content: [{ type: "text", text: "Error: id required for toggle" }], - details: { action: "toggle", todos: [...todos], nextId, error: "id required" }, + details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails, }; } const todo = todos.find((t) => t.id === params.id); if (!todo) { return { content: [{ type: "text", text: `Todo #${params.id} not found` }], - details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` }, + details: { + action: "toggle", + todos: [...todos], + nextId, + error: `#${params.id} not found`, + } as TodoDetails, }; } todo.done = !todo.done; return { content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], - details: { action: "toggle", todos: [...todos], nextId }, + details: { action: "toggle", todos: [...todos], nextId } as TodoDetails, }; } @@ -135,14 +203,19 @@ const factory: CustomToolFactory = (_pi) => { nextId = 1; return { content: [{ type: "text", text: `Cleared ${count} todos` }], - details: { action: "clear", todos: [], nextId: 1 }, + details: { action: "clear", todos: [], nextId: 1 } as TodoDetails, }; } default: return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], - details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` }, + details: { + action: "list", + todos: [...todos], + nextId, + error: `unknown action: ${params.action}`, + } as TodoDetails, }; } }, @@ -155,13 +228,12 @@ const factory: CustomToolFactory = (_pi) => { }, renderResult(result, { expanded }, theme) { - const { details } = result; + const details = result.details as TodoDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } - // Error if (details.error) { return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); } @@ -208,9 +280,20 @@ const factory: CustomToolFactory = (_pi) => { return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); } }, - }; + }); - return tool; -}; + // Register the /todos command for users + pi.registerCommand("todos", { + description: "Show all todos on the current branch", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/todos requires interactive mode", "error"); + return; + } -export default factory; + await ctx.ui.custom((_tui, theme, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); + }, + }); +} diff --git a/packages/coding-agent/examples/hooks/tools.ts b/packages/coding-agent/examples/extensions/tools.ts similarity index 92% rename from packages/coding-agent/examples/hooks/tools.ts rename to packages/coding-agent/examples/extensions/tools.ts index 77c57490..7a79bb3f 100644 --- a/packages/coding-agent/examples/hooks/tools.ts +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -1,16 +1,16 @@ /** - * Tools Hook + * Tools Extension * * Provides a /tools command to enable/disable tools interactively. * Tool selection persists across session reloads and respects branch navigation. * * Usage: - * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ + * 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/ * 2. Use /tools to open the tool selector */ +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { getSettingsListTheme } from "@mariozechner/pi-coding-agent"; -import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; // State persisted to session @@ -18,7 +18,7 @@ interface ToolsState { enabledTools: string[]; } -export default function toolsHook(pi: HookAPI) { +export default function toolsExtension(pi: ExtensionAPI) { // Track enabled tools let enabledTools: Set = new Set(); let allTools: string[] = []; @@ -36,7 +36,7 @@ export default function toolsHook(pi: HookAPI) { } // Find the last tools-config entry in the current branch - function restoreFromBranch(ctx: HookContext) { + function restoreFromBranch(ctx: ExtensionContext) { allTools = pi.getAllTools(); // Get entries in current branch only diff --git a/packages/coding-agent/examples/extensions/with-deps/.gitignore b/packages/coding-agent/examples/extensions/with-deps/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/with-deps/index.ts b/packages/coding-agent/examples/extensions/with-deps/index.ts new file mode 100644 index 00000000..23f11a78 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/index.ts @@ -0,0 +1,40 @@ +/** + * Example extension with its own npm dependencies. + * Tests that jiti resolves modules from the extension's own node_modules. + * + * Requires: npm install in this directory + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import ms from "ms"; + +export default function (pi: ExtensionAPI) { + // Use the ms package to prove it loaded + const uptime = ms(process.uptime() * 1000, { long: true }); + console.log(`[with-deps] Extension loaded. Process uptime: ${uptime}`); + + // Register a tool that uses ms + pi.registerTool({ + name: "parse_duration", + label: "Parse Duration", + description: "Parse a human-readable duration string (e.g., '2 days', '1h', '5m') to milliseconds", + parameters: Type.Object({ + duration: Type.String({ description: "Duration string like '2 days', '1h', '5m'" }), + }), + execute: async (_toolCallId, params) => { + const result = ms(params.duration as ms.StringValue); + if (result === undefined) { + return { + content: [{ type: "text", text: `Invalid duration: "${params.duration}"` }], + isError: true, + details: {}, + }; + } + return { + content: [{ type: "text", text: `${params.duration} = ${result} milliseconds` }], + details: {}, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json new file mode 100644 index 00000000..a8b99608 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "pi-extension-with-deps", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-with-deps", + "version": "1.0.0", + "dependencies": { + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/ms": "^2.1.0" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json new file mode 100644 index 00000000..1edb8245 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -0,0 +1,16 @@ +{ + "name": "pi-extension-with-deps", + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/ms": "^2.1.0" + } +} diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md deleted file mode 100644 index 49f4b66d..00000000 --- a/packages/coding-agent/examples/hooks/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Hooks Examples - -Example hooks for pi-coding-agent. - -## Usage - -```bash -# Load a hook with --hook flag -pi --hook examples/hooks/permission-gate.ts - -# Or copy to hooks directory for auto-discovery -cp permission-gate.ts ~/.pi/agent/hooks/ -``` - -## Examples - -| Hook | Description | -|------|-------------| -| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command | -| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence | -| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt | -| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | -| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | -| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | -| `file-trigger.ts` | Watches a trigger file and injects contents into conversation | -| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | -| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | -| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | -| `custom-compaction.ts` | Custom compaction that summarizes entire conversation | -| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | -| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | -| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | -| `handoff.ts` | Transfer context to a new focused session via `/handoff ` | -| `todo/` | Adds `/todos` command to view todos managed by the [todo custom tool](../custom-tools/todo/) | - -## Writing Hooks - -See [docs/hooks.md](../../docs/hooks.md) for full documentation. - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - // Subscribe to events - pi.on("tool_call", async (event, ctx) => { - if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { - const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); - if (!ok) return { block: true, reason: "Blocked by user" }; - } - }); - - // Register custom commands - pi.registerCommand("hello", { - description: "Say hello", - handler: async (args, ctx) => { - ctx.ui.notify("Hello!", "info"); - }, - }); -} -``` diff --git a/packages/coding-agent/examples/hooks/todo/index.ts b/packages/coding-agent/examples/hooks/todo/index.ts deleted file mode 100644 index 4f4a7df1..00000000 --- a/packages/coding-agent/examples/hooks/todo/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Todo Hook - Companion to the todo custom tool - * - * Registers a /todos command that displays all todos on the current branch - * with a nice custom UI. - */ - -import type { HookAPI, Theme } from "@mariozechner/pi-coding-agent"; -import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; - -interface Todo { - id: number; - text: string; - done: boolean; -} - -interface TodoDetails { - action: "list" | "add" | "toggle" | "clear"; - todos: Todo[]; - nextId: number; - error?: string; -} - -class TodoListComponent { - private todos: Todo[]; - private theme: Theme; - private onClose: () => void; - private cachedWidth?: number; - private cachedLines?: string[]; - - constructor(todos: Todo[], theme: Theme, onClose: () => void) { - this.todos = todos; - this.theme = theme; - this.onClose = onClose; - } - - handleInput(data: string): void { - if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { - this.onClose(); - } - } - - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; - } - - const lines: string[] = []; - const th = this.theme; - - // Header - lines.push(""); - const title = th.fg("accent", " Todos "); - const headerLine = - th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); - lines.push(truncateToWidth(headerLine, width)); - lines.push(""); - - if (this.todos.length === 0) { - lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); - } else { - // Stats - const done = this.todos.filter((t) => t.done).length; - const total = this.todos.length; - const statsText = ` ${th.fg("muted", `${done}/${total} completed`)}`; - lines.push(truncateToWidth(statsText, width)); - lines.push(""); - - // Todo items - for (const todo of this.todos) { - const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); - const id = th.fg("accent", `#${todo.id}`); - const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); - const line = ` ${check} ${id} ${text}`; - lines.push(truncateToWidth(line, width)); - } - } - - lines.push(""); - lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); - lines.push(""); - - this.cachedWidth = width; - this.cachedLines = lines; - return lines; - } - - invalidate(): void { - this.cachedWidth = undefined; - this.cachedLines = undefined; - } -} - -export default function (pi: HookAPI) { - /** - * Reconstruct todos from session entries on the current branch. - */ - function getTodos(ctx: { - sessionManager: { - getBranch: () => Array<{ type: string; message?: { role?: string; toolName?: string; details?: unknown } }>; - }; - }): Todo[] { - let todos: Todo[] = []; - - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type !== "message") continue; - const msg = entry.message; - if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue; - - const details = msg.details as TodoDetails | undefined; - if (details) { - todos = details.todos; - } - } - - return todos; - } - - pi.registerCommand("todos", { - description: "Show all todos on the current branch", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("/todos requires interactive mode", "error"); - return; - } - - const todos = getTodos(ctx); - - await ctx.ui.custom((_tui, theme, done) => { - return new TodoListComponent(todos, theme, () => done()); - }); - }, - }); -} diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts index 80045132..c3b24149 100644 --- a/packages/coding-agent/examples/sdk/01-minimal.ts +++ b/packages/coding-agent/examples/sdk/01-minimal.ts @@ -1,7 +1,7 @@ /** * Minimal SDK Usage * - * Uses all defaults: discovers skills, hooks, tools, context files + * Uses all defaults: discovers skills, extensions, tools, context files * from cwd and ~/.pi/agent. Model chosen from settings or first available. */ diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index 09592cbf..57648bb0 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -1,27 +1,28 @@ /** * Tools Configuration * - * Use built-in tool sets, individual tools, or add custom tools. + * Use built-in tool sets or individual tools. * * IMPORTANT: When using a custom `cwd`, you must use the tool factory functions * (createCodingTools, createReadOnlyTools, createReadTool, etc.) to ensure * tools resolve paths relative to your cwd, not process.cwd(). + * + * For custom tools, see 06-extensions.ts - custom tools are now registered + * via the extensions system using pi.registerTool(). */ import { - bashTool, // read, bash, edit, write - uses process.cwd() - type CustomTool, + bashTool, createAgentSession, createBashTool, - createCodingTools, // Factory: creates tools for specific cwd + createCodingTools, createGrepTool, createReadTool, grepTool, - readOnlyTools, // read, grep, find, ls - uses process.cwd() + readOnlyTools, readTool, SessionManager, } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; // Read-only mode (no edit/write) - uses process.cwd() await createAgentSession({ @@ -53,38 +54,3 @@ await createAgentSession({ sessionManager: SessionManager.inMemory(), }); console.log("Specific tools with custom cwd session created"); - -// Inline custom tool (needs TypeBox schema) -const weatherTool: CustomTool = { - name: "get_weather", - label: "Get Weather", - description: "Get current weather for a city", - parameters: Type.Object({ - city: Type.String({ description: "City name" }), - }), - execute: async (_toolCallId, params) => ({ - content: [{ type: "text", text: `Weather in ${(params as { city: string }).city}: 22°C, sunny` }], - details: {}, - }), -}; - -const { session } = await createAgentSession({ - customTools: [{ tool: weatherTool }], - sessionManager: SessionManager.inMemory(), -}); - -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); - -await session.prompt("What's the weather in Tokyo?"); -console.log(); - -// Merge with discovered tools from cwd/.pi/tools and ~/.pi/agent/tools: -// const discovered = await discoverCustomTools(); -// customTools: [...discovered, { tool: myTool }] - -// Or add paths without replacing discovery: -// additionalCustomToolPaths: ["/extra/tools"] diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts new file mode 100644 index 00000000..b0ecb48f --- /dev/null +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -0,0 +1,81 @@ +/** + * Extensions Configuration + * + * Extensions intercept agent events and can register custom tools. + * They provide a unified system for extensions, custom tools, commands, and more. + * + * Extension files are discovered from: + * - ~/.pi/agent/extensions/ + * - /.pi/extensions/ + * - Paths specified in settings.json "extensions" array + * - Paths passed via --extension CLI flag + * + * An extension is a TypeScript file that exports a default function: + * export default function (pi: ExtensionAPI) { ... } + */ + +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; + +// Extensions are loaded from disk, not passed inline to createAgentSession. +// Use the discovery mechanism: +// 1. Place extension files in ~/.pi/agent/extensions/ or .pi/extensions/ +// 2. Add paths to settings.json: { "extensions": ["./my-extension.ts"] } +// 3. Use --extension flag: pi --extension ./my-extension.ts + +// To add additional extension paths beyond discovery: +const { session } = await createAgentSession({ + additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"], + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("List files in the current directory."); +console.log(); + +// Example extension file (./my-logging-extension.ts): +/* +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("agent_start", async () => { + console.log("[Extension] Agent starting"); + }); + + pi.on("tool_call", async (event) => { + console.log(\`[Extension] Tool: \${event.toolName}\`); + // Return { block: true, reason: "..." } to block execution + return undefined; + }); + + pi.on("agent_end", async (event) => { + console.log(\`[Extension] Done, \${event.messages.length} messages\`); + }); + + // Register a custom tool + pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "Does something useful", + parameters: Type.Object({ + input: Type.String(), + }), + execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({ + content: [{ type: "text", text: \`Processed: \${params.input}\` }], + details: {}, + }), + }); + + // Register a command + pi.registerCommand("mycommand", { + description: "Do something", + handler: async (args, ctx) => { + ctx.ui.notify(\`Command executed with: \${args}\`); + }, + }); +} +*/ diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts deleted file mode 100644 index d0a7a07f..00000000 --- a/packages/coding-agent/examples/sdk/06-hooks.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Hooks Configuration - * - * Hooks intercept agent events for logging, blocking, or modification. - */ - -import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent"; - -// Logging hook -const loggingHook: HookFactory = (api) => { - api.on("agent_start", async () => { - console.log("[Hook] Agent starting"); - }); - - api.on("tool_call", async (event) => { - console.log(`[Hook] Tool: ${event.toolName}`); - return undefined; // Don't block - }); - - api.on("agent_end", async (event) => { - console.log(`[Hook] Done, ${event.messages.length} messages`); - }); -}; - -// Blocking hook (returns { block: true, reason: "..." }) -const safetyHook: HookFactory = (api) => { - api.on("tool_call", async (event) => { - if (event.toolName === "bash") { - const cmd = (event.input as { command?: string }).command ?? ""; - if (cmd.includes("rm -rf")) { - return { block: true, reason: "Dangerous command blocked" }; - } - } - return undefined; - }); -}; - -// Use inline hooks -const { session } = await createAgentSession({ - hooks: [{ factory: loggingHook }, { factory: safetyHook }], - sessionManager: SessionManager.inMemory(), -}); - -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); - -await session.prompt("List files in the current directory."); -console.log(); - -// Disable all hooks: -// hooks: [] - -// Merge with discovered hooks: -// const discovered = await discoverHooks(); -// hooks: [...discovered, { factory: myHook }] - -// Add paths without replacing discovery: -// additionalHookPaths: ["/extra/hooks"] diff --git a/packages/coding-agent/examples/sdk/08-prompt-templates.ts b/packages/coding-agent/examples/sdk/08-prompt-templates.ts new file mode 100644 index 00000000..bc3cbff5 --- /dev/null +++ b/packages/coding-agent/examples/sdk/08-prompt-templates.ts @@ -0,0 +1,42 @@ +/** + * Prompt Templates + * + * File-based templates that inject content when invoked with /templatename. + */ + +import { + createAgentSession, + discoverPromptTemplates, + type PromptTemplate, + SessionManager, +} from "@mariozechner/pi-coding-agent"; + +// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/ +const discovered = discoverPromptTemplates(); +console.log("Discovered prompt templates:"); +for (const template of discovered) { + console.log(` /${template.name}: ${template.description}`); +} + +// Define custom templates +const deployTemplate: PromptTemplate = { + name: "deploy", + description: "Deploy the application", + source: "(custom)", + content: `# Deploy Instructions + +1. Build: npm run build +2. Test: npm test +3. Deploy: npm run deploy`, +}; + +// Use discovered + custom templates +await createAgentSession({ + promptTemplates: [...discovered, deployTemplate], + sessionManager: SessionManager.inMemory(), +}); + +console.log(`Session created with ${discovered.length + 1} prompt templates`); + +// Disable prompt templates: +// promptTemplates: [] diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts deleted file mode 100644 index 8c8dc08b..00000000 --- a/packages/coding-agent/examples/sdk/08-slash-commands.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Slash Commands - * - * File-based commands that inject content when invoked with /commandname. - */ - -import { - createAgentSession, - discoverSlashCommands, - type FileSlashCommand, - SessionManager, -} from "@mariozechner/pi-coding-agent"; - -// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/ -const discovered = discoverSlashCommands(); -console.log("Discovered slash commands:"); -for (const cmd of discovered) { - console.log(` /${cmd.name}: ${cmd.description}`); -} - -// Define custom commands -const deployCommand: FileSlashCommand = { - name: "deploy", - description: "Deploy the application", - source: "(custom)", - content: `# Deploy Instructions - -1. Build: npm run build -2. Test: npm test -3. Deploy: npm run deploy`, -}; - -// Use discovered + custom commands -await createAgentSession({ - slashCommands: [...discovered, deployCommand], - sessionManager: SessionManager.inMemory(), -}); - -console.log(`Session created with ${discovered.length + 1} slash commands`); - -// Disable slash commands: -// slashCommands: [] diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 8ae7f5a4..a51c2d37 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -6,21 +6,22 @@ * IMPORTANT: When providing `tools` with a custom `cwd`, use the tool factory * functions (createReadTool, createBashTool, etc.) to ensure tools resolve * paths relative to your cwd. + * + * NOTE: Extensions (extensions, custom tools) are always loaded via discovery. + * To use custom extensions, place them in the extensions directory or + * pass paths via additionalExtensionPaths. */ import { getModel } from "@mariozechner/pi-ai"; import { AuthStorage, - type CustomTool, createAgentSession, createBashTool, createReadTool, - type HookFactory, ModelRegistry, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; // Custom auth storage location const authStorage = new AuthStorage("/tmp/my-agent/auth.json"); @@ -33,27 +34,7 @@ if (process.env.MY_ANTHROPIC_KEY) { // Model registry with no custom models.json const modelRegistry = new ModelRegistry(authStorage); -// Inline hook -const auditHook: HookFactory = (api) => { - api.on("tool_call", async (event) => { - console.log(`[Audit] ${event.toolName}`); - return undefined; - }); -}; - -// Inline custom tool -const statusTool: CustomTool = { - name: "status", - label: "Status", - description: "Get system status", - parameters: Type.Object({}), - execute: async () => ({ - content: [{ type: "text", text: `Uptime: ${process.uptime()}s, Node: ${process.version}` }], - details: {}, - }), -}; - -const model = getModel("anthropic", "claude-opus-4-5"); +const model = getModel("anthropic", "claude-sonnet-4-20250514"); if (!model) throw new Error("Model not found"); // In-memory settings with overrides @@ -73,14 +54,14 @@ const { session } = await createAgentSession({ authStorage, modelRegistry, systemPrompt: `You are a minimal assistant. -Available: read, bash, status. Be concise.`, +Available: read, bash. Be concise.`, // Use factory functions with the same cwd to ensure path resolution works correctly tools: [createReadTool(cwd), createBashTool(cwd)], - customTools: [{ tool: statusTool }], - hooks: [{ factory: auditHook }], + // Extensions are loaded from disk - use additionalExtensionPaths to add custom ones + // additionalExtensionPaths: ["./my-extension.ts"], skills: [], contextFiles: [], - slashCommands: [], + promptTemplates: [], sessionManager: SessionManager.inMemory(), settingsManager, }); @@ -91,5 +72,5 @@ session.subscribe((event) => { } }); -await session.prompt("Get status and list files."); +await session.prompt("List files in the current directory."); console.log(); diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index ada77836..9096c0bc 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -11,7 +11,7 @@ Programmatic usage of pi-coding-agent via `createAgentSession()`. | `03-custom-prompt.ts` | Replace or modify system prompt | | `04-skills.ts` | Discover, filter, or replace skills | | `05-tools.ts` | Built-in tools, custom tools | -| `06-hooks.ts` | Logging, blocking, result modification | +| `06-extensions.ts` | Logging, blocking, result modification | | `07-context-files.ts` | AGENTS.md context files | | `08-slash-commands.ts` | File-based slash commands | | `09-api-keys-and-oauth.ts` | API key resolution, OAuth config | @@ -36,7 +36,7 @@ import { discoverAuthStorage, discoverModels, discoverSkills, - discoverHooks, + discoverExtensions, discoverCustomTools, discoverContextFiles, discoverSlashCommands, @@ -89,7 +89,7 @@ const { session } = await createAgentSession({ systemPrompt: "You are helpful.", tools: [readTool, bashTool], customTools: [{ tool: myTool }], - hooks: [{ factory: myHook }], + extensions: [{ factory: myExtension }], skills: [], contextFiles: [], slashCommands: [], @@ -119,8 +119,8 @@ await session.prompt("Hello"); | `tools` | `codingTools` | Built-in tools | | `customTools` | Discovered | Replaces discovery | | `additionalCustomToolPaths` | `[]` | Merge with discovery | -| `hooks` | Discovered | Replaces discovery | -| `additionalHookPaths` | `[]` | Merge with discovery | +| `extensions` | Discovered | Replaces discovery | +| `additionalExtensionPaths` | `[]` | Merge with discovery | | `skills` | Discovered | Skills for prompt | | `contextFiles` | Discovered | AGENTS.md files | | `slashCommands` | Discovered | File commands | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d51866e4..cbb11a79 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -26,8 +26,7 @@ export interface Args { sessionDir?: string; models?: string[]; tools?: ToolName[]; - hooks?: string[]; - customTools?: string[]; + extensions?: string[]; print?: boolean; export?: string; noSkills?: boolean; @@ -35,7 +34,7 @@ export interface Args { listModels?: string | true; messages: string[]; fileArgs: string[]; - /** Unknown flags (potentially hook flags) - map of flag name to value */ + /** Unknown flags (potentially extension flags) - map of flag name to value */ unknownFlags: Map; } @@ -45,7 +44,7 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel { return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); } -export function parseArgs(args: string[], hookFlags?: Map): Args { +export function parseArgs(args: string[], extensionFlags?: Map): Args { const result: Args = { messages: [], fileArgs: [], @@ -114,12 +113,9 @@ export function parseArgs(args: string[], hookFlags?: Map Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh - --hook Load a hook file (can be used multiple times) - --tool Load a custom tool file (can be used multiple times) + --extension, -e Load an extension file (can be used multiple times) --no-skills Disable skills discovery and loading --skills Comma-separated glob patterns to filter skills (e.g., git-*,docker) --export Export session file to HTML and exit @@ -187,7 +182,7 @@ ${chalk.bold("Options:")} --help, -h Show this help --version, -v Show version number -Hooks can register additional flags (e.g., --plan from plan-mode hook). +Extensions can register additional flags (e.g., --plan from plan-mode extension). ${chalk.bold("Examples:")} # Interactive mode diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 202eb6b6..817b6ea1 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -147,9 +147,9 @@ export function getToolsDir(): string { return join(getAgentDir(), "tools"); } -/** Get path to slash commands directory */ -export function getCommandsDir(): string { - return join(getAgentDir(), "commands"); +/** Get path to prompt templates directory */ +export function getPromptsDir(): string { + return join(getAgentDir(), "prompts"); } /** Get path to sessions directory */ diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 59e6883d..1bba813a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -34,10 +34,9 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.js"; -import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html/index.js"; import type { - HookRunner, + ExtensionRunner, SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, @@ -45,12 +44,12 @@ import type { TreePreparation, TurnEndEvent, TurnStartEvent, -} from "./hooks/index.js"; -import type { BashExecutionMessage, HookMessage } from "./messages.js"; +} from "./extensions/index.js"; +import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; +import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; -import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = @@ -73,16 +72,14 @@ export interface AgentSessionConfig { settingsManager: SettingsManager; /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - /** File-based slash commands for expansion */ - fileCommands?: FileSlashCommand[]; - /** Hook runner (created in main.ts with wrapped tools) */ - hookRunner?: HookRunner; - /** Custom tools for session lifecycle events */ - customTools?: LoadedCustomTool[]; + /** File-based prompt templates for expansion */ + promptTemplates?: PromptTemplate[]; + /** Extension runner (created in sdk.ts with wrapped tools) */ + extensionRunner?: ExtensionRunner; skillsSettings?: Required; /** Model registry for API key resolution and model discovery */ modelRegistry: ModelRegistry; - /** Tool registry for hook getTools/setTools - maps name to tool */ + /** Tool registry for extension getTools/setTools - maps name to tool */ toolRegistry?: Map; /** Function to rebuild system prompt when tools change */ rebuildSystemPrompt?: (toolNames: string[]) => string; @@ -90,8 +87,8 @@ export interface AgentSessionConfig { /** Options for AgentSession.prompt() */ export interface PromptOptions { - /** Whether to expand file-based slash commands (default: true) */ - expandSlashCommands?: boolean; + /** Whether to expand file-based prompt templates (default: true) */ + expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ @@ -145,7 +142,7 @@ export class AgentSession { readonly settingsManager: SettingsManager; private _scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - private _fileCommands: FileSlashCommand[]; + private _promptTemplates: PromptTemplate[]; // Event subscription state private _unsubscribeAgent?: () => void; @@ -156,7 +153,7 @@ export class AgentSession { /** Tracks pending follow-up messages for UI display. Removed when delivered. */ private _followUpMessages: string[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ - private _pendingNextTurnMessages: HookMessage[] = []; + private _pendingNextTurnMessages: CustomMessage[] = []; // Compaction state private _compactionAbortController: AbortController | undefined = undefined; @@ -175,25 +172,22 @@ export class AgentSession { private _bashAbortController: AbortController | undefined = undefined; private _pendingBashMessages: BashExecutionMessage[] = []; - // Hook system - private _hookRunner: HookRunner | undefined = undefined; + // Extension system + private _extensionRunner: ExtensionRunner | undefined = undefined; private _turnIndex = 0; - // Custom tools for session lifecycle - private _customTools: LoadedCustomTool[] = []; - private _skillsSettings: Required | undefined; // Model registry for API key resolution private _modelRegistry: ModelRegistry; - // Tool registry for hook getTools/setTools + // Tool registry for extension getTools/setTools private _toolRegistry: Map; // Function to rebuild system prompt when tools change private _rebuildSystemPrompt?: (toolNames: string[]) => string; - // Base system prompt (without hook appends) - used to apply fresh appends each turn + // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt: string; constructor(config: AgentSessionConfig) { @@ -201,9 +195,8 @@ export class AgentSession { this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; - this._fileCommands = config.fileCommands ?? []; - this._hookRunner = config.hookRunner; - this._customTools = config.customTools ?? []; + this._promptTemplates = config.promptTemplates ?? []; + this._extensionRunner = config.extensionRunner; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; this._toolRegistry = config.toolRegistry ?? new Map(); @@ -211,7 +204,7 @@ export class AgentSession { this._baseSystemPrompt = config.agent.state.systemPrompt; // Always subscribe to agent events for internal handling - // (session persistence, hooks, auto-compaction, retry logic) + // (session persistence, extensions, auto-compaction, retry logic) this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } @@ -255,16 +248,16 @@ export class AgentSession { } } - // Emit to hooks first - await this._emitHookEvent(event); + // Emit to extensions first + await this._emitExtensionEvent(event); // Notify all listeners this._emit(event); // Handle session persistence if (event.type === "message_end") { - // Check if this is a hook message - if (event.message.role === "hookMessage") { + // Check if this is a custom message from extensions + if (event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, @@ -343,30 +336,30 @@ export class AgentSession { return undefined; } - /** Emit hook events based on agent events */ - private async _emitHookEvent(event: AgentEvent): Promise { - if (!this._hookRunner) return; + /** Emit extension events based on agent events */ + private async _emitExtensionEvent(event: AgentEvent): Promise { + if (!this._extensionRunner) return; if (event.type === "agent_start") { this._turnIndex = 0; - await this._hookRunner.emit({ type: "agent_start" }); + await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - await this._hookRunner.emit({ type: "agent_end", messages: event.messages }); + await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { - const hookEvent: TurnStartEvent = { + const extensionEvent: TurnStartEvent = { type: "turn_start", turnIndex: this._turnIndex, timestamp: Date.now(), }; - await this._hookRunner.emit(hookEvent); + await this._extensionRunner.emit(extensionEvent); } else if (event.type === "turn_end") { - const hookEvent: TurnEndEvent = { + const extensionEvent: TurnEndEvent = { type: "turn_end", turnIndex: this._turnIndex, message: event.message, toolResults: event.toolResults, }; - await this._hookRunner.emit(hookEvent); + await this._extensionRunner.emit(extensionEvent); this._turnIndex++; } } @@ -517,9 +510,9 @@ export class AgentSession { return this._scopedModels; } - /** File-based slash commands */ - get fileCommands(): ReadonlyArray { - return this._fileCommands; + /** File-based prompt templates */ + get promptTemplates(): ReadonlyArray { + return this._promptTemplates; } // ========================================================================= @@ -528,28 +521,28 @@ export class AgentSession { /** * Send a prompt to the agent. - * - Handles hook commands (registered via pi.registerCommand) immediately, even during streaming - * - Expands file-based slash commands by default + * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming + * - Expands file-based prompt templates by default * - During streaming, queues via steer() or followUp() based on streamingBehavior option * - Validates model and API key before sending (when not streaming) * @throws Error if streaming and no streamingBehavior specified * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { - const expandCommands = options?.expandSlashCommands ?? true; + const expandPromptTemplates = options?.expandPromptTemplates ?? true; - // Handle hook commands first (execute immediately, even during streaming) - // Hook commands manage their own LLM interaction via pi.sendMessage() - if (expandCommands && text.startsWith("/")) { - const handled = await this._tryExecuteHookCommand(text); + // Handle extension commands first (execute immediately, even during streaming) + // Extension commands manage their own LLM interaction via pi.sendMessage() + if (expandPromptTemplates && text.startsWith("/")) { + const handled = await this._tryExecuteExtensionCommand(text); if (handled) { - // Hook command executed, no prompt to send + // Extension command executed, no prompt to send return; } } - // Expand file-based slash commands if requested - const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; + // Expand file-based prompt templates if requested + const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text; // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { @@ -593,7 +586,7 @@ export class AgentSession { await this._checkCompaction(lastAssistant, false); } - // Build messages array (hook message if any, then user message) + // Build messages array (custom message if any, then user message) const messages: AgentMessage[] = []; // Add user message @@ -613,14 +606,14 @@ export class AgentSession { } this._pendingNextTurnMessages = []; - // Emit before_agent_start hook event - if (this._hookRunner) { - const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); - // Add all hook messages + // Emit before_agent_start extension event + if (this._extensionRunner) { + const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images); + // Add all custom messages from extensions if (result?.messages) { for (const msg of result.messages) { messages.push({ - role: "hookMessage", + role: "custom", customType: msg.customType, content: msg.content, display: msg.display, @@ -629,7 +622,7 @@ export class AgentSession { }); } } - // Apply hook systemPromptAppend on top of base prompt + // Apply extension systemPromptAppend on top of base prompt if (result?.systemPromptAppend) { this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`); } else { @@ -643,29 +636,29 @@ export class AgentSession { } /** - * Try to execute a hook command. Returns true if command was found and executed. + * Try to execute an extension command. Returns true if command was found and executed. */ - private async _tryExecuteHookCommand(text: string): Promise { - if (!this._hookRunner) return false; + private async _tryExecuteExtensionCommand(text: string): Promise { + if (!this._extensionRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); - const command = this._hookRunner.getCommand(commandName); + const command = this._extensionRunner.getCommand(commandName); if (!command) return false; - // Get command context from hook runner (includes session control methods) - const ctx = this._hookRunner.createCommandContext(); + // Get command context from extension runner (includes session control methods) + const ctx = this._extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { - // Emit error via hook runner - this._hookRunner.emitError({ - hookPath: `command:${commandName}`, + // Emit error via extension runner + this._extensionRunner.emitError({ + extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); @@ -676,17 +669,17 @@ export class AgentSession { /** * Queue a steering message to interrupt the agent mid-run. * Delivered after current tool execution, skips remaining tools. - * Expands file-based slash commands. Errors on hook commands. - * @throws Error if text is a hook command + * Expands file-based prompt templates. Errors on extension commands. + * @throws Error if text is an extension command */ async steer(text: string): Promise { - // Check for hook commands (cannot be queued) + // Check for extension commands (cannot be queued) if (text.startsWith("/")) { - this._throwIfHookCommand(text); + this._throwIfExtensionCommand(text); } - // Expand file-based slash commands - const expandedText = expandSlashCommand(text, [...this._fileCommands]); + // Expand file-based prompt templates + const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); await this._queueSteer(expandedText); } @@ -694,23 +687,23 @@ export class AgentSession { /** * Queue a follow-up message to be processed after the agent finishes. * Delivered only when agent has no more tool calls or steering messages. - * Expands file-based slash commands. Errors on hook commands. - * @throws Error if text is a hook command + * Expands file-based prompt templates. Errors on extension commands. + * @throws Error if text is an extension command */ async followUp(text: string): Promise { - // Check for hook commands (cannot be queued) + // Check for extension commands (cannot be queued) if (text.startsWith("/")) { - this._throwIfHookCommand(text); + this._throwIfExtensionCommand(text); } - // Expand file-based slash commands - const expandedText = expandSlashCommand(text, [...this._fileCommands]); + // Expand file-based prompt templates + const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); await this._queueFollowUp(expandedText); } /** - * Internal: Queue a steering message (already expanded, no hook command check). + * Internal: Queue a steering message (already expanded, no extension command check). */ private async _queueSteer(text: string): Promise { this._steeringMessages.push(text); @@ -722,7 +715,7 @@ export class AgentSession { } /** - * Internal: Queue a follow-up message (already expanded, no hook command check). + * Internal: Queue a follow-up message (already expanded, no extension command check). */ private async _queueFollowUp(text: string): Promise { this._followUpMessages.push(text); @@ -734,46 +727,46 @@ export class AgentSession { } /** - * Throw an error if the text is a hook command. + * Throw an error if the text is an extension command. */ - private _throwIfHookCommand(text: string): void { - if (!this._hookRunner) return; + private _throwIfExtensionCommand(text: string): void { + if (!this._extensionRunner) return; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const command = this._hookRunner.getCommand(commandName); + const command = this._extensionRunner.getCommand(commandName); if (command) { throw new Error( - `Hook command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, + `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, ); } } /** - * Send a hook message to the session. Creates a CustomMessageEntry. + * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: * - Streaming: queues message, processed when loop pulls from queue * - Not streaming + triggerTurn: appends to state/session, starts new turn * - Not streaming + no trigger: appends to state/session, no turn * - * @param message Hook message with customType, content, display, details + * @param message Custom message with customType, content, display, details * @param options.triggerTurn If true and not streaming, triggers a new LLM turn * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" */ - async sendHookMessage( - message: Pick, "customType" | "content" | "display" | "details">, + async sendCustomMessage( + message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { const appMessage = { - role: "hookMessage" as const, + role: "custom" as const, customType: message.customType, content: message.content, display: message.display, details: message.details, timestamp: Date.now(), - } satisfies HookMessage; + } satisfies CustomMessage; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming) { @@ -842,14 +835,14 @@ export class AgentSession { * Clears all messages and starts a new session. * Listeners are preserved and will continue receiving events. * @param options - Optional initial messages and parent session path - * @returns true if completed, false if cancelled by hook + * @returns true if completed, false if cancelled by extension */ async newSession(options?: NewSessionOptions): Promise { const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "new" (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_switch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "new", })) as SessionBeforeSwitchResult | undefined; @@ -868,9 +861,9 @@ export class AgentSession { this._pendingNextTurnMessages = []; this._reconnectToAgent(); - // Emit session_switch event with reason "new" to hooks - if (this._hookRunner) { - await this._hookRunner.emit({ + // Emit session_switch event with reason "new" to extensions + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_switch", reason: "new", previousSessionFile, @@ -878,7 +871,6 @@ export class AgentSession { } // Emit session event to custom tools - await this.emitCustomToolSessionEvent("switch", previousSessionFile); return true; } @@ -1097,11 +1089,11 @@ export class AgentSession { throw new Error("Nothing to compact (session too small)"); } - let hookCompaction: CompactionResult | undefined; - let fromHook = false; + let extensionCompaction: CompactionResult | undefined; + let fromExtension = false; - if (this._hookRunner?.hasHandlers("session_before_compact")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_compact")) { + const result = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, @@ -1114,8 +1106,8 @@ export class AgentSession { } if (result?.compaction) { - hookCompaction = result.compaction; - fromHook = true; + extensionCompaction = result.compaction; + fromExtension = true; } } @@ -1124,12 +1116,12 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Hook provided compaction content - summary = hookCompaction.summary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; + if (extensionCompaction) { + // Extension provided compaction content + summary = extensionCompaction.summary; + firstKeptEntryId = extensionCompaction.firstKeptEntryId; + tokensBefore = extensionCompaction.tokensBefore; + details = extensionCompaction.details; } else { // Generate compaction result const result = await compact( @@ -1149,21 +1141,21 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); - // Get the saved compaction entry for the hook + // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; - if (this._hookRunner && savedCompactionEntry) { - await this._hookRunner.emit({ + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, - fromHook, + fromExtension, }); } @@ -1265,11 +1257,11 @@ export class AgentSession { return; } - let hookCompaction: CompactionResult | undefined; - let fromHook = false; + let extensionCompaction: CompactionResult | undefined; + let fromExtension = false; - if (this._hookRunner?.hasHandlers("session_before_compact")) { - const hookResult = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_compact")) { + const extensionResult = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, @@ -1277,14 +1269,14 @@ export class AgentSession { signal: this._autoCompactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; - if (hookResult?.cancel) { + if (extensionResult?.cancel) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } - if (hookResult?.compaction) { - hookCompaction = hookResult.compaction; - fromHook = true; + if (extensionResult?.compaction) { + extensionCompaction = extensionResult.compaction; + fromExtension = true; } } @@ -1293,12 +1285,12 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Hook provided compaction content - summary = hookCompaction.summary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; + if (extensionCompaction) { + // Extension provided compaction content + summary = extensionCompaction.summary; + firstKeptEntryId = extensionCompaction.firstKeptEntryId; + tokensBefore = extensionCompaction.tokensBefore; + details = extensionCompaction.details; } else { // Generate compaction result const compactResult = await compact( @@ -1319,21 +1311,21 @@ export class AgentSession { return; } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); - // Get the saved compaction entry for the hook + // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as | CompactionEntry | undefined; - if (this._hookRunner && savedCompactionEntry) { - await this._hookRunner.emit({ + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, - fromHook, + fromExtension, }); } @@ -1632,14 +1624,14 @@ export class AgentSession { * Switch to a different session file. * Aborts current operation, loads messages, restores model/thinking. * Listeners are preserved and will continue receiving events. - * @returns true if switch completed, false if cancelled by hook + * @returns true if switch completed, false if cancelled by extension */ async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionManager.getSessionFile(); // Emit session_before_switch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_switch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "resume", targetSessionFile: sessionPath, @@ -1662,9 +1654,9 @@ export class AgentSession { // Reload messages const sessionContext = this.sessionManager.buildSessionContext(); - // Emit session_switch event to hooks - if (this._hookRunner) { - await this._hookRunner.emit({ + // Emit session_switch event to extensions + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_switch", reason: "resume", previousSessionFile, @@ -1672,7 +1664,6 @@ export class AgentSession { } // Emit session event to custom tools - await this.emitCustomToolSessionEvent("switch", previousSessionFile); this.agent.replaceMessages(sessionContext.messages); @@ -1698,12 +1689,12 @@ export class AgentSession { /** * Create a branch from a specific entry. - * Emits before_branch/branch session events to hooks. + * Emits before_branch/branch session events to extensions. * * @param entryId ID of the entry to branch from * @returns Object with: * - selectedText: The text of the selected user message (for editor pre-fill) - * - cancelled: True if a hook cancelled the branch + * - cancelled: True if an extension cancelled the branch */ async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> { const previousSessionFile = this.sessionFile; @@ -1718,8 +1709,8 @@ export class AgentSession { let skipConversationRestore = false; // Emit session_before_branch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_branch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_branch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_branch", entryId, })) as SessionBeforeBranchResult | undefined; @@ -1742,16 +1733,15 @@ export class AgentSession { // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.sessionManager.buildSessionContext(); - // Emit session_branch event to hooks (after branch completes) - if (this._hookRunner) { - await this._hookRunner.emit({ + // Emit session_branch event to extensions (after branch completes) + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_branch", previousSessionFile, }); } // Emit session event to custom tools (with reason "branch") - await this.emitCustomToolSessionEvent("branch", previousSessionFile); if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); @@ -1812,12 +1802,12 @@ export class AgentSession { // Set up abort controller for summarization this._branchSummaryAbortController = new AbortController(); - let hookSummary: { summary: string; details?: unknown } | undefined; - let fromHook = false; + let extensionSummary: { summary: string; details?: unknown } | undefined; + let fromExtension = false; // Emit session_before_tree event - if (this._hookRunner?.hasHandlers("session_before_tree")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_tree")) { + const result = (await this._extensionRunner.emit({ type: "session_before_tree", preparation, signal: this._branchSummaryAbortController.signal, @@ -1828,15 +1818,15 @@ export class AgentSession { } if (result?.summary && options.summarize) { - hookSummary = result.summary; - fromHook = true; + extensionSummary = result.summary; + fromExtension = true; } } // Run default summarizer if needed let summaryText: string | undefined; let summaryDetails: unknown; - if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { + if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; const apiKey = await this._modelRegistry.getApiKey(model); if (!apiKey) { @@ -1862,9 +1852,9 @@ export class AgentSession { readFiles: result.readFiles || [], modifiedFiles: result.modifiedFiles || [], }; - } else if (hookSummary) { - summaryText = hookSummary.summary; - summaryDetails = hookSummary.details; + } else if (extensionSummary) { + summaryText = extensionSummary.summary; + summaryDetails = extensionSummary.details; } // Determine the new leaf position based on target type @@ -1895,7 +1885,7 @@ export class AgentSession { let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook); + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf @@ -1910,18 +1900,17 @@ export class AgentSession { this.agent.replaceMessages(sessionContext.messages); // Emit session_tree event - if (this._hookRunner) { - await this._hookRunner.emit({ + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_tree", newLeafId: this.sessionManager.getLeafId(), oldLeafId, summaryEntry, - fromHook: summaryText ? fromHook : undefined, + fromExtension: summaryText ? fromExtension : undefined, }); } // Emit to custom tools - await this.emitCustomToolSessionEvent("tree", this.sessionFile); this._branchSummaryAbortController = undefined; return { editorText, cancelled: false, summaryEntry }; @@ -2049,60 +2038,20 @@ export class AgentSession { } // ========================================================================= - // Hook System + // Extension System // ========================================================================= /** - * Check if hooks have handlers for a specific event type. + * Check if extensions have handlers for a specific event type. */ - hasHookHandlers(eventType: string): boolean { - return this._hookRunner?.hasHandlers(eventType) ?? false; + hasExtensionHandlers(eventType: string): boolean { + return this._extensionRunner?.hasHandlers(eventType) ?? false; } /** - * Get the hook runner (for setting UI context and error handlers). + * Get the extension runner (for setting UI context and error handlers). */ - get hookRunner(): HookRunner | undefined { - return this._hookRunner; - } - - /** - * Get custom tools (for setting UI context in modes). - */ - get customTools(): LoadedCustomTool[] { - return this._customTools; - } - - /** - * Emit session event to all custom tools. - * Called on session switch, branch, tree navigation, and shutdown. - */ - async emitCustomToolSessionEvent( - reason: CustomToolSessionEvent["reason"], - previousSessionFile?: string | undefined, - ): Promise { - if (!this._customTools) return; - - const event: CustomToolSessionEvent = { reason, previousSessionFile }; - const ctx: CustomToolContext = { - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, - model: this.agent.state.model, - isIdle: () => !this.isStreaming, - hasPendingMessages: () => this.pendingMessageCount > 0, - abort: () => { - this.abort(); - }, - }; - - for (const { tool } of this._customTools) { - if (tool.onSession) { - try { - await tool.onSession(event, ctx); - } catch (_err) { - // Silently ignore tool errors during session events - } - } - } + get extensionRunner(): ExtensionRunner | undefined { + return this._extensionRunner; } } diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index d3b4f59d..20763e8b 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -12,7 +12,7 @@ import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, - createHookMessage, + createCustomMessage, } from "../messages.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import { estimateTokens } from "./compaction.js"; @@ -147,7 +147,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { return entry.message; case "custom_message": - return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); @@ -184,7 +184,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // First pass: collect file ops from ALL entries (even if they don't fit in token budget) // This ensures we capture cumulative file tracking from nested branch summaries - // Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones + // Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones for (const entry of entries) { if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { const details = entry.details as BranchSummaryDetails; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index d46bdfb7..4c0b77a0 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -8,7 +8,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete, completeSimple } from "@mariozechner/pi-ai"; -import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; +import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; import { computeFileLists, @@ -44,6 +44,7 @@ function extractFileOperations( if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; if (!prevCompaction.fromHook && prevCompaction.details) { + // fromHook field kept for session file compatibility const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -75,7 +76,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { return entry.message; } if (entry.type === "custom_message") { - return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); } if (entry.type === "branch_summary") { return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); @@ -88,7 +89,7 @@ export interface CompactionResult { summary: string; firstKeptEntryId: string; tokensBefore: number; - /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; } @@ -194,7 +195,7 @@ export function estimateTokens(message: AgentMessage): number { } return Math.ceil(chars / 4); } - case "hookMessage": + case "custom": case "toolResult": { if (typeof message.content === "string") { chars = message.content.length; @@ -240,7 +241,7 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde const role = entry.message.role; switch (role) { case "bashExecution": - case "hookMessage": + case "custom": case "branchSummary": case "compactionSummary": case "user": @@ -477,7 +478,7 @@ export async function generateSummary( } // Serialize conversation to text so model doesn't try to continue it - // Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.) + // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) const llmMessages = convertToLlm(currentMessages); const conversationText = serializeConversation(llmMessages); @@ -515,7 +516,7 @@ export async function generateSummary( } // ============================================================================ -// Compaction Preparation (for hooks) +// Compaction Preparation (for extensions) // ============================================================================ export interface CompactionPreparation { diff --git a/packages/coding-agent/src/core/custom-tools/index.ts b/packages/coding-agent/src/core/custom-tools/index.ts deleted file mode 100644 index adb0b705..00000000 --- a/packages/coding-agent/src/core/custom-tools/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Custom tools module. - */ - -export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js"; -export type { - AgentToolResult, - AgentToolUpdateCallback, - CustomTool, - CustomToolAPI, - CustomToolContext, - CustomToolFactory, - CustomToolResult, - CustomToolSessionEvent, - CustomToolsLoadResult, - CustomToolUIContext, - ExecResult, - LoadedCustomTool, - RenderResultOptions, -} from "./types.js"; -export { wrapCustomTool, wrapCustomTools } from "./wrapper.js"; diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts deleted file mode 100644 index b04c6f1c..00000000 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Custom tool loader - loads TypeScript tool modules using jiti. - * - * For Bun compiled binaries, custom tools that import from @mariozechner/* packages - * are not supported because Bun's plugin system doesn't intercept imports from - * external files loaded at runtime. Users should use the npm-installed version - * for custom tools that depend on pi packages. - */ - -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { createJiti } from "jiti"; -import { getAgentDir, isBunBinary } from "../../config.js"; -import { theme } from "../../modes/interactive/theme/theme.js"; -import { createEventBus, type EventBus } from "../event-bus.js"; -import type { ExecOptions } from "../exec.js"; -import { execCommand } from "../exec.js"; -import type { HookUIContext } from "../hooks/types.js"; -import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js"; - -// Create require function to resolve module paths at runtime -const require = createRequire(import.meta.url); - -// Lazily computed aliases - resolved at runtime to handle global installs -let _aliases: Record | null = null; -function getAliases(): Record { - if (_aliases) return _aliases; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const packageIndex = path.resolve(__dirname, "../..", "index.js"); - - // For typebox, we need the package root directory (not the entry file) - // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" - // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), - // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). - // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. - const typeboxEntry = require.resolve("@sinclair/typebox"); - const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); - - _aliases = { - "@mariozechner/pi-coding-agent": packageIndex, - "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), - "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": typeboxRoot, - }; - return _aliases; -} - -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; - -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} - -function expandPath(p: string): string { - const normalized = normalizeUnicodeSpaces(p); - if (normalized.startsWith("~/")) { - return path.join(os.homedir(), normalized.slice(2)); - } - if (normalized.startsWith("~")) { - return path.join(os.homedir(), normalized.slice(1)); - } - return normalized; -} - -/** - * Resolve tool path. - * - Absolute paths used as-is - * - Paths starting with ~ expanded to home directory - * - Relative paths resolved from cwd - */ -function resolveToolPath(toolPath: string, cwd: string): string { - const expanded = expandPath(toolPath); - - if (path.isAbsolute(expanded)) { - return expanded; - } - - // Relative paths resolved from cwd - return path.resolve(cwd, expanded); -} - -/** - * Create a no-op UI context for headless modes. - */ -function createNoOpUIContext(): HookUIContext { - return { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: () => {}, - setStatus: () => {}, - setWidget: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - get theme() { - return theme; - }, - }; -} - -/** - * Load a tool in Bun binary mode. - * - * Since Bun plugins don't work for dynamically loaded external files, - * custom tools that import from @mariozechner/* packages won't work. - * Tools that only use standard npm packages (installed in the tool's directory) - * may still work. - */ -async function loadToolWithBun( - resolvedPath: string, - sharedApi: CustomToolAPI, -): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { - try { - // Try to import directly - will work for tools without @mariozechner/* imports - const module = await import(resolvedPath); - const factory = (module.default ?? module) as CustomToolFactory; - - if (typeof factory !== "function") { - return { tools: null, error: "Tool must export a default function" }; - } - - const toolResult = await factory(sharedApi); - const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult]; - - const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ - path: resolvedPath, - resolvedPath, - tool, - })); - - return { tools: loadedTools, error: null }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - - // Check if it's a module resolution error for our packages - if (message.includes("Cannot find module") && message.includes("@mariozechner/")) { - return { - tools: null, - error: - `${message}\n` + - "Note: Custom tools importing from @mariozechner/* packages are not supported in the standalone binary.\n" + - "Please install pi via npm: npm install -g @mariozechner/pi-coding-agent", - }; - } - - return { tools: null, error: `Failed to load tool: ${message}` }; - } -} - -/** - * Load a single tool module using jiti (or Bun.build for compiled binaries). - */ -async function loadTool( - toolPath: string, - cwd: string, - sharedApi: CustomToolAPI, -): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { - const resolvedPath = resolveToolPath(toolPath, cwd); - - // Use Bun.build for compiled binaries since jiti can't resolve bundled modules - if (isBunBinary) { - return loadToolWithBun(resolvedPath, sharedApi); - } - - try { - // Create jiti instance for TypeScript/ESM loading - // Use aliases to resolve package imports since tools are loaded from user directories - // (e.g. ~/.pi/agent/tools) but import from packages installed with pi-coding-agent - const jiti = createJiti(import.meta.url, { - alias: getAliases(), - }); - - // Import the module - const module = await jiti.import(resolvedPath, { default: true }); - const factory = module as CustomToolFactory; - - if (typeof factory !== "function") { - return { tools: null, error: "Tool must export a default function" }; - } - - // Call factory with shared API - const result = await factory(sharedApi); - - // Handle single tool or array of tools - const toolsArray = Array.isArray(result) ? result : [result]; - - const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ - path: toolPath, - resolvedPath, - tool, - })); - - return { tools: loadedTools, error: null }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { tools: null, error: `Failed to load tool: ${message}` }; - } -} - -/** - * Load all tools from configuration. - * @param paths - Array of tool file paths - * @param cwd - Current working directory for resolving relative paths - * @param builtInToolNames - Names of built-in tools to check for conflicts - */ -export async function loadCustomTools( - paths: string[], - cwd: string, - builtInToolNames: string[], - eventBus?: EventBus, -): Promise { - const tools: LoadedCustomTool[] = []; - const errors: Array<{ path: string; error: string }> = []; - const seenNames = new Set(builtInToolNames); - const resolvedEventBus = eventBus ?? createEventBus(); - - // Shared API object - all tools get the same instance - const sharedApi: CustomToolAPI = { - cwd, - exec: (command: string, args: string[], options?: ExecOptions) => - execCommand(command, args, options?.cwd ?? cwd, options), - ui: createNoOpUIContext(), - hasUI: false, - events: resolvedEventBus, - sendMessage: () => {}, - }; - - for (const toolPath of paths) { - const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi); - - if (error) { - errors.push({ path: toolPath, error }); - continue; - } - - if (loadedTools) { - for (const loadedTool of loadedTools) { - // Check for name conflicts - if (seenNames.has(loadedTool.tool.name)) { - errors.push({ - path: toolPath, - error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`, - }); - continue; - } - - seenNames.add(loadedTool.tool.name); - tools.push(loadedTool); - } - } - } - - return { - tools, - errors, - setUIContext(uiContext, hasUI) { - sharedApi.ui = uiContext; - sharedApi.hasUI = hasUI; - }, - setSendMessageHandler(handler) { - sharedApi.sendMessage = handler; - }, - }; -} - -/** - * Discover tool files from a directory. - * Only loads index.ts files from subdirectories (e.g., tools/mytool/index.ts). - */ -function discoverToolsInDir(dir: string): string[] { - if (!fs.existsSync(dir)) { - return []; - } - - const tools: string[] = []; - - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory() || entry.isSymbolicLink()) { - // Check for index.ts in subdirectory - const indexPath = path.join(dir, entry.name, "index.ts"); - if (fs.existsSync(indexPath)) { - tools.push(indexPath); - } - } - } - } catch { - return []; - } - - return tools; -} - -/** - * Discover and load tools from standard locations: - * 1. agentDir/tools/*.ts (global) - * 2. cwd/.pi/tools/*.ts (project-local) - * - * Plus any explicitly configured paths from settings or CLI. - * - * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags - * @param cwd - Current working directory - * @param builtInToolNames - Names of built-in tools to check for conflicts - * @param agentDir - Agent config directory. Default: from getAgentDir() - * @param eventBus - Optional shared event bus (creates isolated bus if not provided) - */ -export async function discoverAndLoadCustomTools( - configuredPaths: string[], - cwd: string, - builtInToolNames: string[], - agentDir: string = getAgentDir(), - eventBus?: EventBus, -): Promise { - const allPaths: string[] = []; - const seen = new Set(); - - // Helper to add paths without duplicates - const addPaths = (paths: string[]) => { - for (const p of paths) { - const resolved = path.resolve(p); - if (!seen.has(resolved)) { - seen.add(resolved); - allPaths.push(p); - } - } - }; - - // 1. Global tools: agentDir/tools/ - const globalToolsDir = path.join(agentDir, "tools"); - addPaths(discoverToolsInDir(globalToolsDir)); - - // 2. Project-local tools: cwd/.pi/tools/ - const localToolsDir = path.join(cwd, ".pi", "tools"); - addPaths(discoverToolsInDir(localToolsDir)); - - // 3. Explicitly configured paths (can override/add) - addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd))); - - return loadCustomTools(allPaths, cwd, builtInToolNames, eventBus); -} diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts deleted file mode 100644 index 80eb290b..00000000 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Custom tool types. - * - * Custom tools are TypeScript modules that define additional tools for the agent. - * They can provide custom rendering for tool calls and results in the TUI. - */ - -import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; -import type { Component } from "@mariozechner/pi-tui"; -import type { Static, TSchema } from "@sinclair/typebox"; -import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { EventBus } from "../event-bus.js"; -import type { ExecOptions, ExecResult } from "../exec.js"; -import type { HookUIContext } from "../hooks/types.js"; -import type { HookMessage } from "../messages.js"; -import type { ModelRegistry } from "../model-registry.js"; -import type { ReadonlySessionManager } from "../session-manager.js"; - -/** Alias for clarity */ -export type CustomToolUIContext = HookUIContext; - -/** Re-export for custom tools to use in execute signature */ -export type { AgentToolResult, AgentToolUpdateCallback }; - -// Re-export for backward compatibility -export type { ExecOptions, ExecResult } from "../exec.js"; - -/** API passed to custom tool factory (stable across session changes) */ -export interface CustomToolAPI { - /** Current working directory */ - cwd: string; - /** Execute a command */ - exec(command: string, args: string[], options?: ExecOptions): Promise; - /** UI methods for user interaction (select, confirm, input, notify, custom) */ - ui: CustomToolUIContext; - /** Whether UI is available (false in print/RPC mode) */ - hasUI: boolean; - /** Shared event bus for tool/hook communication */ - events: EventBus; - /** - * Send a message to the agent session. - * - * Delivery behavior depends on agent state and options: - * - Streaming + "steer" (default): Interrupt mid-run, delivered after current tool. - * - Streaming + "followUp": Wait until agent finishes before delivery. - * - Idle + triggerTurn: Triggers a new LLM turn immediately. - * - Idle + "nextTurn": Queue to be included with the next user message as context. - * - Idle + neither: Append to session history as standalone entry. - * - * @param message - The message to send - * @param message.customType - Identifier for your tool - * @param message.content - Message content (string or TextContent/ImageContent array) - * @param message.display - Whether to show in TUI (true = styled display, false = hidden) - * @param message.details - Optional tool-specific metadata (not sent to LLM) - * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn - * @param options.deliverAs - Delivery mode: "steer", "followUp", or "nextTurn" - */ - sendMessage( - message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, - ): void; -} - -/** - * Context passed to tool execute and onSession callbacks. - * Provides access to session state and model information. - */ -export interface CustomToolContext { - /** Session manager (read-only) */ - sessionManager: ReadonlySessionManager; - /** Model registry - use for API key resolution and model retrieval */ - modelRegistry: ModelRegistry; - /** Current model (may be undefined if no model is selected yet) */ - model: Model | undefined; - /** Whether the agent is idle (not streaming) */ - isIdle(): boolean; - /** Whether there are queued messages waiting to be processed */ - hasPendingMessages(): boolean; - /** Abort the current agent operation (fire-and-forget, does not wait) */ - abort(): void; -} - -/** Session event passed to onSession callback */ -export interface CustomToolSessionEvent { - /** Reason for the session event */ - reason: "start" | "switch" | "branch" | "tree" | "shutdown"; - /** Previous session file path, or undefined for "start" and "shutdown" */ - previousSessionFile: string | undefined; -} - -/** Rendering options passed to renderResult */ -export interface RenderResultOptions { - /** Whether the result view is expanded */ - expanded: boolean; - /** Whether this is a partial/streaming result */ - isPartial: boolean; -} - -export type CustomToolResult = AgentToolResult; - -/** - * Custom tool definition. - * - * Custom tools are standalone - they don't extend AgentTool directly. - * When loaded, they are wrapped in an AgentTool for the agent to use. - * - * The execute callback receives a ToolContext with access to session state, - * model registry, and current model. - * - * @example - * ```typescript - * const factory: CustomToolFactory = (pi) => ({ - * name: "my_tool", - * label: "My Tool", - * description: "Does something useful", - * parameters: Type.Object({ input: Type.String() }), - * - * async execute(toolCallId, params, onUpdate, ctx, signal) { - * // Access session state via ctx.sessionManager - * // Access model registry via ctx.modelRegistry - * // Current model via ctx.model - * return { content: [{ type: "text", text: "Done" }] }; - * }, - * - * onSession(event, ctx) { - * if (event.reason === "shutdown") { - * // Cleanup - * } - * // Reconstruct state from ctx.sessionManager.getEntries() - * } - * }); - * ``` - */ -export interface CustomTool { - /** Tool name (used in LLM tool calls) */ - name: string; - /** Human-readable label for UI */ - label: string; - /** Description for LLM */ - description: string; - /** Parameter schema (TypeBox) */ - parameters: TParams; - - /** - * Execute the tool. - * @param toolCallId - Unique ID for this tool call - * @param params - Parsed parameters matching the schema - * @param onUpdate - Callback for streaming partial results (for UI, not LLM) - * @param ctx - Context with session manager, model registry, and current model - * @param signal - Optional abort signal for cancellation - */ - execute( - toolCallId: string, - params: Static, - onUpdate: AgentToolUpdateCallback | undefined, - ctx: CustomToolContext, - signal?: AbortSignal, - ): Promise>; - - /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ - onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise; - /** Custom rendering for tool call display - return a Component */ - renderCall?: (args: Static, theme: Theme) => Component; - - /** Custom rendering for tool result display - return a Component */ - renderResult?: (result: CustomToolResult, options: RenderResultOptions, theme: Theme) => Component; -} - -/** Factory function that creates a custom tool or array of tools */ -export type CustomToolFactory = ( - pi: CustomToolAPI, -) => CustomTool | CustomTool[] | Promise | CustomTool[]>; - -/** Loaded custom tool with metadata and wrapped AgentTool */ -export interface LoadedCustomTool { - /** Original path (as specified) */ - path: string; - /** Resolved absolute path */ - resolvedPath: string; - /** The original custom tool instance */ - tool: CustomTool; -} - -/** Send message handler type for tool sendMessage */ -export type ToolSendMessageHandler = ( - message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, -) => void; - -/** Result from loading custom tools */ -export interface CustomToolsLoadResult { - tools: LoadedCustomTool[]; - errors: Array<{ path: string; error: string }>; - /** Update the UI context for all loaded tools. Call when mode initializes. */ - setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void; - /** Set the sendMessage handler for all loaded tools. Call when session initializes. */ - setSendMessageHandler(handler: ToolSendMessageHandler): void; -} diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts deleted file mode 100644 index b24ee028..00000000 --- a/packages/coding-agent/src/core/custom-tools/wrapper.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Wraps CustomTool instances into AgentTool for use with the agent. - */ - -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js"; - -/** - * Wrap a CustomTool into an AgentTool. - * The wrapper injects the ToolContext into execute calls. - */ -export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool { - return { - name: tool.name, - label: tool.label, - description: tool.description, - parameters: tool.parameters, - execute: (toolCallId, params, signal, onUpdate) => - tool.execute(toolCallId, params, onUpdate, getContext(), signal), - }; -} - -/** - * Wrap all loaded custom tools into AgentTools. - */ -export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] { - return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext)); -} diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index fccf5504..b7dd046c 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -1,5 +1,5 @@ /** - * Shared command execution utilities for hooks and custom tools. + * Shared command execution utilities for extensions and custom tools. */ import { spawn } from "node:child_process"; diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 0a563b70..58929706 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -1,5 +1,5 @@ /** - * Extension system - unified hooks and custom tools. + * Extension system for lifecycle events and custom tools. */ export { discoverAndLoadExtensions, loadExtensions } from "./loader.js"; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 39791161..6eaa5be3 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -4,7 +4,7 @@ * Extensions are TypeScript modules that can: * - Subscribe to agent lifecycle events * - Register LLM-callable tools - * - Register slash commands, keyboard shortcuts, and CLI flags + * - Register commands, keyboard shortcuts, and CLI flags * - Interact with the user via UI primitives */ @@ -16,7 +16,7 @@ import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { EventBus } from "../event-bus.js"; import type { ExecOptions, ExecResult } from "../exec.js"; -import type { HookMessage } from "../messages.js"; +import type { CustomMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; import type { BranchSummaryEntry, @@ -119,7 +119,7 @@ export interface ExtensionContext { } /** - * Extended context for slash command handlers. + * Extended context for command handlers. * Includes session control methods only safe in user-initiated commands. */ export interface ExtensionCommandContext extends ExtensionContext { @@ -228,7 +228,7 @@ export interface SessionBeforeCompactEvent { export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; - fromHook: boolean; + fromExtension: boolean; } /** Fired on process exit */ @@ -258,7 +258,7 @@ export interface SessionTreeEvent { newLeafId: string | null; oldLeafId: string | null; summaryEntry?: BranchSummaryEntry; - fromHook?: boolean; + fromExtension?: boolean; } export type SessionEvent = @@ -442,7 +442,7 @@ export interface ToolResultEventResult { } export interface BeforeAgentStartEventResult { - message?: Pick; + message?: Pick; systemPromptAppend?: string; } @@ -477,7 +477,7 @@ export interface MessageRenderOptions { } export type MessageRenderer = ( - message: HookMessage, + message: CustomMessage, options: MessageRenderOptions, theme: Theme, ) => Component | undefined; @@ -547,7 +547,7 @@ export interface ExtensionAPI { // Command, Shortcut, Flag Registration // ========================================================================= - /** Register a custom slash command. */ + /** Register a custom command. */ registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; /** Register a keyboard shortcut. */ @@ -576,7 +576,7 @@ export interface ExtensionAPI { // Message Rendering // ========================================================================= - /** Register a custom renderer for HookMessageEntry. */ + /** Register a custom renderer for CustomMessageEntry. */ registerMessageRenderer(customType: string, renderer: MessageRenderer): void; // ========================================================================= @@ -585,7 +585,7 @@ export interface ExtensionAPI { /** Send a custom message to the session. */ sendMessage( - message: Pick, "customType" | "content" | "display" | "details">, + message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): void; @@ -638,7 +638,7 @@ export interface ExtensionShortcut { type HandlerFn = (...args: unknown[]) => Promise; export type SendMessageHandler = ( - message: Pick, "customType" | "content" | "display" | "details">, + message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ) => void; diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts deleted file mode 100644 index a0cd381b..00000000 --- a/packages/coding-agent/src/core/hooks/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -// biome-ignore assist/source/organizeImports: biome is not smart -export { - discoverAndLoadHooks, - loadHooks, - type AppendEntryHandler, - type BranchHandler, - type GetActiveToolsHandler, - type GetAllToolsHandler, - type HookFlag, - type HookShortcut, - type LoadedHook, - type LoadHooksResult, - type NavigateTreeHandler, - type NewSessionHandler, - type SendMessageHandler, - type SetActiveToolsHandler, -} from "./loader.js"; -export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; -export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; -export * from "./types.js"; -export type { ReadonlySessionManager } from "../session-manager.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts deleted file mode 100644 index f9c61e9f..00000000 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** - * Hook loader - loads TypeScript hook modules using jiti. - */ - -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { KeyId } from "@mariozechner/pi-tui"; -import { createJiti } from "jiti"; -import { getAgentDir } from "../../config.js"; -import { createEventBus, type EventBus } from "../event-bus.js"; -import type { HookMessage } from "../messages.js"; -import type { SessionManager } from "../session-manager.js"; -import { execCommand } from "./runner.js"; -import type { - ExecOptions, - HookAPI, - HookContext, - HookFactory, - HookMessageRenderer, - RegisteredCommand, -} from "./types.js"; - -// Create require function to resolve module paths at runtime -const require = createRequire(import.meta.url); - -// Lazily computed aliases - resolved at runtime to handle global installs -let _aliases: Record | null = null; -function getAliases(): Record { - if (_aliases) return _aliases; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const packageIndex = path.resolve(__dirname, "../..", "index.js"); - - // For typebox, we need the package root directory (not the entry file) - // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" - // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), - // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). - // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. - const typeboxEntry = require.resolve("@sinclair/typebox"); - const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); - - _aliases = { - "@mariozechner/pi-coding-agent": packageIndex, - "@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"), - "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), - "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": typeboxRoot, - }; - return _aliases; -} - -/** - * Generic handler function type. - */ -type HandlerFn = (...args: unknown[]) => Promise; - -/** - * Send message handler type for pi.sendMessage(). - */ -export type SendMessageHandler = ( - message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, -) => void; - -/** - * Append entry handler type for pi.appendEntry(). - */ -export type AppendEntryHandler = (customType: string, data?: T) => void; - -/** - * Get active tools handler type for pi.getActiveTools(). - */ -export type GetActiveToolsHandler = () => string[]; - -/** - * Get all tools handler type for pi.getAllTools(). - */ -export type GetAllToolsHandler = () => string[]; - -/** - * Set active tools handler type for pi.setActiveTools(). - */ -export type SetActiveToolsHandler = (toolNames: string[]) => void; - -/** - * CLI flag definition registered by a hook. - */ -export interface HookFlag { - /** Flag name (without --) */ - name: string; - /** Description for --help */ - description?: string; - /** Type: boolean or string */ - type: "boolean" | "string"; - /** Default value */ - default?: boolean | string; - /** Hook path that registered this flag */ - hookPath: string; -} - -/** - * Keyboard shortcut registered by a hook. - */ -export interface HookShortcut { - /** Key identifier (e.g., Key.shift("p"), "ctrl+x") */ - shortcut: KeyId; - /** Description for help */ - description?: string; - /** Handler function */ - handler: (ctx: HookContext) => Promise | void; - /** Hook path that registered this shortcut */ - hookPath: string; -} - -/** - * New session handler type for ctx.newSession() in HookCommandContext. - */ -export type NewSessionHandler = (options?: { - parentSession?: string; - setup?: (sessionManager: SessionManager) => Promise; -}) => Promise<{ cancelled: boolean }>; - -/** - * Branch handler type for ctx.branch() in HookCommandContext. - */ -export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>; - -/** - * Navigate tree handler type for ctx.navigateTree() in HookCommandContext. - */ -export type NavigateTreeHandler = ( - targetId: string, - options?: { summarize?: boolean }, -) => Promise<{ cancelled: boolean }>; - -/** - * Registered handlers for a loaded hook. - */ -export interface LoadedHook { - /** Original path from config */ - path: string; - /** Resolved absolute path */ - resolvedPath: string; - /** Map of event type to handler functions */ - handlers: Map; - /** Map of customType to hook message renderer */ - messageRenderers: Map; - /** Map of command name to registered command */ - commands: Map; - /** CLI flags registered by this hook */ - flags: Map; - /** Flag values (set after CLI parsing) */ - flagValues: Map; - /** Keyboard shortcuts registered by this hook */ - shortcuts: Map; - /** Set the send message handler for this hook's pi.sendMessage() */ - setSendMessageHandler: (handler: SendMessageHandler) => void; - /** Set the append entry handler for this hook's pi.appendEntry() */ - setAppendEntryHandler: (handler: AppendEntryHandler) => void; - /** Set the get active tools handler for this hook's pi.getActiveTools() */ - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; - /** Set the get all tools handler for this hook's pi.getAllTools() */ - setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; - /** Set the set active tools handler for this hook's pi.setActiveTools() */ - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; - /** Set a flag value (called after CLI parsing) */ - setFlagValue: (name: string, value: boolean | string) => void; -} - -/** - * Result of loading hooks. - */ -export interface LoadHooksResult { - /** Successfully loaded hooks */ - hooks: LoadedHook[]; - /** Errors encountered during loading */ - errors: Array<{ path: string; error: string }>; -} - -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; - -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} - -function expandPath(p: string): string { - const normalized = normalizeUnicodeSpaces(p); - if (normalized.startsWith("~/")) { - return path.join(os.homedir(), normalized.slice(2)); - } - if (normalized.startsWith("~")) { - return path.join(os.homedir(), normalized.slice(1)); - } - return normalized; -} - -/** - * Resolve hook path. - * - Absolute paths used as-is - * - Paths starting with ~ expanded to home directory - * - Relative paths resolved from cwd - */ -function resolveHookPath(hookPath: string, cwd: string): string { - const expanded = expandPath(hookPath); - - if (path.isAbsolute(expanded)) { - return expanded; - } - - // Relative paths resolved from cwd - return path.resolve(cwd, expanded); -} - -/** - * Create a HookAPI instance that collects handlers, renderers, and commands. - * Returns the API, maps, and functions to set handlers later. - */ -function createHookAPI( - handlers: Map, - cwd: string, - hookPath: string, - eventBus: EventBus, -): { - api: HookAPI; - messageRenderers: Map; - commands: Map; - flags: Map; - flagValues: Map; - shortcuts: Map; - setSendMessageHandler: (handler: SendMessageHandler) => void; - setAppendEntryHandler: (handler: AppendEntryHandler) => void; - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; - setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; - setFlagValue: (name: string, value: boolean | string) => void; -} { - let sendMessageHandler: SendMessageHandler = () => { - // Default no-op until mode sets the handler - }; - let appendEntryHandler: AppendEntryHandler = () => { - // Default no-op until mode sets the handler - }; - let getActiveToolsHandler: GetActiveToolsHandler = () => []; - let getAllToolsHandler: GetAllToolsHandler = () => []; - let setActiveToolsHandler: SetActiveToolsHandler = () => { - // Default no-op until mode sets the handler - }; - const messageRenderers = new Map(); - const commands = new Map(); - const flags = new Map(); - const flagValues = new Map(); - const shortcuts = new Map(); - - // Cast to HookAPI - the implementation is more general (string event names) - // but the interface has specific overloads for type safety in hooks - const api = { - on(event: string, handler: HandlerFn): void { - const list = handlers.get(event) ?? []; - list.push(handler); - handlers.set(event, list); - }, - sendMessage( - message: HookMessage, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, - ): void { - sendMessageHandler(message, options); - }, - appendEntry(customType: string, data?: T): void { - appendEntryHandler(customType, data); - }, - registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void { - messageRenderers.set(customType, renderer as HookMessageRenderer); - }, - registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { - commands.set(name, { name, ...options }); - }, - exec(command: string, args: string[], options?: ExecOptions) { - return execCommand(command, args, options?.cwd ?? cwd, options); - }, - getActiveTools(): string[] { - return getActiveToolsHandler(); - }, - getAllTools(): string[] { - return getAllToolsHandler(); - }, - setActiveTools(toolNames: string[]): void { - setActiveToolsHandler(toolNames); - }, - registerFlag( - name: string, - options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, - ): void { - flags.set(name, { name, hookPath, ...options }); - if (options.default !== undefined) { - flagValues.set(name, options.default); - } - }, - getFlag(name: string): boolean | string | undefined { - return flagValues.get(name); - }, - registerShortcut( - shortcut: KeyId, - options: { - description?: string; - handler: (ctx: HookContext) => Promise | void; - }, - ): void { - shortcuts.set(shortcut, { shortcut, hookPath, ...options }); - }, - events: eventBus, - } as HookAPI; - - return { - api, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler: (handler: SendMessageHandler) => { - sendMessageHandler = handler; - }, - setAppendEntryHandler: (handler: AppendEntryHandler) => { - appendEntryHandler = handler; - }, - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => { - getActiveToolsHandler = handler; - }, - setGetAllToolsHandler: (handler: GetAllToolsHandler) => { - getAllToolsHandler = handler; - }, - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => { - setActiveToolsHandler = handler; - }, - setFlagValue: (name: string, value: boolean | string) => { - flagValues.set(name, value); - }, - }; -} - -/** - * Load a single hook module using jiti. - */ -async function loadHook( - hookPath: string, - cwd: string, - eventBus: EventBus, -): Promise<{ hook: LoadedHook | null; error: string | null }> { - const resolvedPath = resolveHookPath(hookPath, cwd); - - try { - // Create jiti instance for TypeScript/ESM loading - // Use aliases to resolve package imports since hooks are loaded from user directories - // (e.g. ~/.pi/agent/hooks) but import from packages installed with pi-coding-agent - const jiti = createJiti(import.meta.url, { - alias: getAliases(), - }); - - // Import the module - const module = await jiti.import(resolvedPath, { default: true }); - const factory = module as HookFactory; - - if (typeof factory !== "function") { - return { hook: null, error: "Hook must export a default function" }; - } - - // Create handlers map and API - const handlers = new Map(); - const { - api, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - } = createHookAPI(handlers, cwd, hookPath, eventBus); - - // Call factory to register handlers - factory(api); - - return { - hook: { - path: hookPath, - resolvedPath, - handlers, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - }, - error: null, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { hook: null, error: `Failed to load hook: ${message}` }; - } -} - -/** - * Load all hooks from configuration. - * @param paths - Array of hook file paths - * @param cwd - Current working directory for resolving relative paths - * @param eventBus - Optional shared event bus (creates isolated bus if not provided) - */ -export async function loadHooks(paths: string[], cwd: string, eventBus?: EventBus): Promise { - const hooks: LoadedHook[] = []; - const errors: Array<{ path: string; error: string }> = []; - const resolvedEventBus = eventBus ?? createEventBus(); - - for (const hookPath of paths) { - const { hook, error } = await loadHook(hookPath, cwd, resolvedEventBus); - - if (error) { - errors.push({ path: hookPath, error }); - continue; - } - - if (hook) { - hooks.push(hook); - } - } - - return { hooks, errors }; -} - -/** - * Discover hook files from a directory. - * Returns all .ts files (and symlinks to .ts files) in the directory (non-recursive). - */ -function discoverHooksInDir(dir: string): string[] { - if (!fs.existsSync(dir)) { - return []; - } - - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - return entries - .filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts")) - .map((e) => path.join(dir, e.name)); - } catch { - return []; - } -} - -/** - * Discover and load hooks from standard locations: - * 1. agentDir/hooks/*.ts (global) - * 2. cwd/.pi/hooks/*.ts (project-local) - * - * Plus any explicitly configured paths from settings. - * - * @param configuredPaths - Explicitly configured hook paths - * @param cwd - Current working directory - * @param agentDir - Agent configuration directory - * @param eventBus - Optional shared event bus (creates isolated bus if not provided) - */ -export async function discoverAndLoadHooks( - configuredPaths: string[], - cwd: string, - agentDir: string = getAgentDir(), - eventBus?: EventBus, -): Promise { - const allPaths: string[] = []; - const seen = new Set(); - - // Helper to add paths without duplicates - const addPaths = (paths: string[]) => { - for (const p of paths) { - const resolved = path.resolve(p); - if (!seen.has(resolved)) { - seen.add(resolved); - allPaths.push(p); - } - } - }; - - // 1. Global hooks: agentDir/hooks/ - const globalHooksDir = path.join(agentDir, "hooks"); - addPaths(discoverHooksInDir(globalHooksDir)); - - // 2. Project-local hooks: cwd/.pi/hooks/ - const localHooksDir = path.join(cwd, ".pi", "hooks"); - addPaths(discoverHooksInDir(localHooksDir)); - - // 3. Explicitly configured paths (can override/add) - addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd))); - - return loadHooks(allPaths, cwd, eventBus); -} diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts deleted file mode 100644 index 57f55346..00000000 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ /dev/null @@ -1,552 +0,0 @@ -/** - * Hook runner - executes hooks and manages their lifecycle. - */ - -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ImageContent, Model } from "@mariozechner/pi-ai"; -import type { KeyId } from "@mariozechner/pi-tui"; -import { theme } from "../../modes/interactive/theme/theme.js"; -import type { ModelRegistry } from "../model-registry.js"; -import type { SessionManager } from "../session-manager.js"; -import type { - AppendEntryHandler, - BranchHandler, - HookFlag, - HookShortcut, - LoadedHook, - NavigateTreeHandler, - NewSessionHandler, - SendMessageHandler, -} from "./loader.js"; -import type { - BeforeAgentStartEvent, - BeforeAgentStartEventResult, - ContextEvent, - ContextEventResult, - HookCommandContext, - HookContext, - HookError, - HookEvent, - HookMessageRenderer, - HookUIContext, - RegisteredCommand, - SessionBeforeCompactResult, - SessionBeforeTreeResult, - ToolCallEvent, - ToolCallEventResult, - ToolResultEventResult, -} from "./types.js"; - -/** Combined result from all before_agent_start handlers (internal) */ -interface BeforeAgentStartCombinedResult { - messages?: NonNullable[]; - systemPromptAppend?: string; -} - -/** - * Listener for hook errors. - */ -export type HookErrorListener = (error: HookError) => void; - -// Re-export execCommand for backward compatibility -export { execCommand } from "../exec.js"; - -/** No-op UI context used when no UI is available */ -const noOpUIContext: HookUIContext = { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: () => {}, - setStatus: () => {}, - setWidget: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - get theme() { - return theme; - }, -}; - -/** - * HookRunner executes hooks and manages event emission. - */ -export class HookRunner { - private hooks: LoadedHook[]; - private uiContext: HookUIContext; - private hasUI: boolean; - private cwd: string; - private sessionManager: SessionManager; - private modelRegistry: ModelRegistry; - private errorListeners: Set = new Set(); - private getModel: () => Model | undefined = () => undefined; - private isIdleFn: () => boolean = () => true; - private waitForIdleFn: () => Promise = async () => {}; - private abortFn: () => void = () => {}; - private hasPendingMessagesFn: () => boolean = () => false; - private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); - private branchHandler: BranchHandler = async () => ({ cancelled: false }); - private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); - - constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) { - this.hooks = hooks; - this.uiContext = noOpUIContext; - this.hasUI = false; - this.cwd = cwd; - this.sessionManager = sessionManager; - this.modelRegistry = modelRegistry; - } - - /** - * Initialize HookRunner with all required context. - * Modes call this once the agent session is fully set up. - */ - initialize(options: { - /** Function to get the current model */ - getModel: () => Model | undefined; - /** Handler for hooks to send messages */ - sendMessageHandler: SendMessageHandler; - /** Handler for hooks to append entries */ - appendEntryHandler: AppendEntryHandler; - /** Handler for getting current active tools */ - getActiveToolsHandler: () => string[]; - /** Handler for getting all configured tools */ - getAllToolsHandler: () => string[]; - /** Handler for setting active tools */ - setActiveToolsHandler: (toolNames: string[]) => void; - /** Handler for creating new sessions (for HookCommandContext) */ - newSessionHandler?: NewSessionHandler; - /** Handler for branching sessions (for HookCommandContext) */ - branchHandler?: BranchHandler; - /** Handler for navigating session tree (for HookCommandContext) */ - navigateTreeHandler?: NavigateTreeHandler; - /** Function to check if agent is idle */ - isIdle?: () => boolean; - /** Function to wait for agent to be idle */ - waitForIdle?: () => Promise; - /** Function to abort current operation (fire-and-forget) */ - abort?: () => void; - /** Function to check if there are queued messages */ - hasPendingMessages?: () => boolean; - /** UI context for interactive prompts */ - uiContext?: HookUIContext; - /** Whether UI is available */ - hasUI?: boolean; - }): void { - this.getModel = options.getModel; - this.isIdleFn = options.isIdle ?? (() => true); - this.waitForIdleFn = options.waitForIdle ?? (async () => {}); - this.abortFn = options.abort ?? (() => {}); - this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false); - // Store session handlers for HookCommandContext - if (options.newSessionHandler) { - this.newSessionHandler = options.newSessionHandler; - } - if (options.branchHandler) { - this.branchHandler = options.branchHandler; - } - if (options.navigateTreeHandler) { - this.navigateTreeHandler = options.navigateTreeHandler; - } - // Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getActiveTools(), pi.getAllTools(), pi.setActiveTools() - for (const hook of this.hooks) { - hook.setSendMessageHandler(options.sendMessageHandler); - hook.setAppendEntryHandler(options.appendEntryHandler); - hook.setGetActiveToolsHandler(options.getActiveToolsHandler); - hook.setGetAllToolsHandler(options.getAllToolsHandler); - hook.setSetActiveToolsHandler(options.setActiveToolsHandler); - } - this.uiContext = options.uiContext ?? noOpUIContext; - this.hasUI = options.hasUI ?? false; - } - - /** - * Get the UI context (set by mode). - */ - getUIContext(): HookUIContext | null { - return this.uiContext; - } - - /** - * Get whether UI is available. - */ - getHasUI(): boolean { - return this.hasUI; - } - - /** - * Get the paths of all loaded hooks. - */ - getHookPaths(): string[] { - return this.hooks.map((h) => h.path); - } - - /** - * Get all CLI flags registered by hooks. - */ - getFlags(): Map { - const allFlags = new Map(); - for (const hook of this.hooks) { - for (const [name, flag] of hook.flags) { - allFlags.set(name, flag); - } - } - return allFlags; - } - - /** - * Set a flag value (after CLI parsing). - */ - setFlagValue(name: string, value: boolean | string): void { - for (const hook of this.hooks) { - if (hook.flags.has(name)) { - hook.setFlagValue(name, value); - } - } - } - - // Built-in shortcuts that hooks should not override - private static readonly RESERVED_SHORTCUTS = new Set([ - "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", - ]); - - /** - * Get all keyboard shortcuts registered by hooks. - * When multiple hooks register the same shortcut, the last one wins. - * Conflicts with built-in shortcuts are skipped with a warning. - * Conflicts between hooks are logged as warnings. - */ - getShortcuts(): Map { - const allShortcuts = new Map(); - for (const hook of this.hooks) { - for (const [key, shortcut] of hook.shortcuts) { - // Normalize to lowercase for comparison (KeyId is string at runtime) - const normalizedKey = key.toLowerCase() as KeyId; - - // Check for built-in shortcut conflicts - if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { - console.warn( - `Hook shortcut '${key}' from ${shortcut.hookPath} conflicts with built-in shortcut. Skipping.`, - ); - continue; - } - - const existing = allShortcuts.get(normalizedKey); - if (existing) { - // Log conflict between hooks - last one wins - console.warn( - `Hook shortcut conflict: '${key}' registered by both ${existing.hookPath} and ${shortcut.hookPath}. Using ${shortcut.hookPath}.`, - ); - } - allShortcuts.set(normalizedKey, shortcut); - } - } - return allShortcuts; - } - - /** - * Subscribe to hook errors. - * @returns Unsubscribe function - */ - onError(listener: HookErrorListener): () => void { - this.errorListeners.add(listener); - return () => this.errorListeners.delete(listener); - } - - /** - * Emit an error to all listeners. - */ - /** - * Emit an error to all error listeners. - */ - emitError(error: HookError): void { - for (const listener of this.errorListeners) { - listener(error); - } - } - - /** - * Check if any hooks have handlers for the given event type. - */ - hasHandlers(eventType: string): boolean { - for (const hook of this.hooks) { - const handlers = hook.handlers.get(eventType); - if (handlers && handlers.length > 0) { - return true; - } - } - return false; - } - - /** - * Get a message renderer for the given customType. - * Returns the first renderer found across all hooks, or undefined if none. - */ - getMessageRenderer(customType: string): HookMessageRenderer | undefined { - for (const hook of this.hooks) { - const renderer = hook.messageRenderers.get(customType); - if (renderer) { - return renderer; - } - } - return undefined; - } - - /** - * Get all registered commands from all hooks. - */ - getRegisteredCommands(): RegisteredCommand[] { - const commands: RegisteredCommand[] = []; - for (const hook of this.hooks) { - for (const command of hook.commands.values()) { - commands.push(command); - } - } - return commands; - } - - /** - * Get a registered command by name. - * Returns the first command found across all hooks, or undefined if none. - */ - getCommand(name: string): RegisteredCommand | undefined { - for (const hook of this.hooks) { - const command = hook.commands.get(name); - if (command) { - return command; - } - } - return undefined; - } - - /** - * Create the event context for handlers. - */ - private createContext(): HookContext { - return { - ui: this.uiContext, - hasUI: this.hasUI, - cwd: this.cwd, - sessionManager: this.sessionManager, - modelRegistry: this.modelRegistry, - model: this.getModel(), - isIdle: () => this.isIdleFn(), - abort: () => this.abortFn(), - hasPendingMessages: () => this.hasPendingMessagesFn(), - }; - } - - /** - * Create the command context for slash command handlers. - * Extends HookContext with session control methods that are only safe in commands. - */ - createCommandContext(): HookCommandContext { - return { - ...this.createContext(), - waitForIdle: () => this.waitForIdleFn(), - newSession: (options) => this.newSessionHandler(options), - branch: (entryId) => this.branchHandler(entryId), - navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), - }; - } - - /** - * Check if event type is a session "before_*" event that can be cancelled. - */ - private isSessionBeforeEvent( - type: string, - ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" { - return ( - type === "session_before_switch" || - type === "session_before_branch" || - type === "session_before_compact" || - type === "session_before_tree" - ); - } - - /** - * Emit an event to all hooks. - * Returns the result from session before_* / tool_result events (if any handler returns one). - */ - async emit( - event: HookEvent, - ): Promise { - const ctx = this.createContext(); - let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined; - - for (const hook of this.hooks) { - const handlers = hook.handlers.get(event.type); - if (!handlers || handlers.length === 0) continue; - - for (const handler of handlers) { - try { - const handlerResult = await handler(event, ctx); - - // For session before_* events, capture the result (for cancellation) - if (this.isSessionBeforeEvent(event.type) && handlerResult) { - result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; - // If cancelled, stop processing further hooks - if (result.cancel) { - return result; - } - } - - // For tool_result events, capture the result - if (event.type === "tool_result" && handlerResult) { - result = handlerResult as ToolResultEventResult; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error ? err.stack : undefined; - this.emitError({ - hookPath: hook.path, - event: event.type, - error: message, - stack, - }); - } - } - } - - return result; - } - - /** - * Emit a tool_call event to all hooks. - * No timeout - user prompts can take as long as needed. - * Errors are thrown (not swallowed) so caller can block on failure. - */ - async emitToolCall(event: ToolCallEvent): Promise { - const ctx = this.createContext(); - let result: ToolCallEventResult | undefined; - - for (const hook of this.hooks) { - const handlers = hook.handlers.get("tool_call"); - if (!handlers || handlers.length === 0) continue; - - for (const handler of handlers) { - // No timeout - let user take their time - const handlerResult = await handler(event, ctx); - - if (handlerResult) { - result = handlerResult as ToolCallEventResult; - // If blocked, stop processing further hooks - if (result.block) { - return result; - } - } - } - } - - return result; - } - - /** - * Emit a context event to all hooks. - * Handlers are chained - each gets the previous handler's output (if any). - * Returns the final modified messages, or the original if no modifications. - * - * Messages are deep-copied before passing to hooks, so mutations are safe. - */ - async emitContext(messages: AgentMessage[]): Promise { - const ctx = this.createContext(); - let currentMessages = structuredClone(messages); - - for (const hook of this.hooks) { - const handlers = hook.handlers.get("context"); - if (!handlers || handlers.length === 0) continue; - - for (const handler of handlers) { - try { - const event: ContextEvent = { type: "context", messages: currentMessages }; - const handlerResult = await handler(event, ctx); - - if (handlerResult && (handlerResult as ContextEventResult).messages) { - currentMessages = (handlerResult as ContextEventResult).messages!; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error ? err.stack : undefined; - this.emitError({ - hookPath: hook.path, - event: "context", - error: message, - stack, - }); - } - } - } - - return currentMessages; - } - - /** - * Emit before_agent_start event to all hooks. - * Returns combined result: all messages and all systemPromptAppend strings concatenated. - */ - async emitBeforeAgentStart( - prompt: string, - images?: ImageContent[], - ): Promise { - const ctx = this.createContext(); - const messages: NonNullable[] = []; - const systemPromptAppends: string[] = []; - - for (const hook of this.hooks) { - const handlers = hook.handlers.get("before_agent_start"); - if (!handlers || handlers.length === 0) continue; - - for (const handler of handlers) { - try { - const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; - const handlerResult = await handler(event, ctx); - - if (handlerResult) { - const result = handlerResult as BeforeAgentStartEventResult; - // Collect all messages - if (result.message) { - messages.push(result.message); - } - // Collect all systemPromptAppend strings - if (result.systemPromptAppend) { - systemPromptAppends.push(result.systemPromptAppend); - } - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error ? err.stack : undefined; - this.emitError({ - hookPath: hook.path, - event: "before_agent_start", - error: message, - stack, - }); - } - } - } - - // Return combined result - if (messages.length > 0 || systemPromptAppends.length > 0) { - return { - messages: messages.length > 0 ? messages : undefined, - systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined, - }; - } - - return undefined; - } -} diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts deleted file mode 100644 index 28c718f0..00000000 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Tool wrapper - wraps tools with hook callbacks for interception. - */ - -import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; -import type { HookRunner } from "./runner.js"; -import type { ToolCallEventResult, ToolResultEventResult } from "./types.js"; - -/** - * Wrap a tool with hook callbacks. - * - Emits tool_call event before execution (can block) - * - Emits tool_result event after execution (can modify result) - * - Forwards onUpdate callback to wrapped tool for progress streaming - */ -export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRunner): AgentTool { - return { - ...tool, - execute: async ( - toolCallId: string, - params: Record, - signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, - ) => { - // Emit tool_call event - hooks can block execution - // If hook errors/times out, block by default (fail-safe) - if (hookRunner.hasHandlers("tool_call")) { - try { - const callResult = (await hookRunner.emitToolCall({ - type: "tool_call", - toolName: tool.name, - toolCallId, - input: params, - })) as ToolCallEventResult | undefined; - - if (callResult?.block) { - const reason = callResult.reason || "Tool execution was blocked by a hook"; - throw new Error(reason); - } - } catch (err) { - // Hook error or block - throw to mark as error - if (err instanceof Error) { - throw err; - } - throw new Error(`Hook failed, blocking execution: ${String(err)}`); - } - } - - // Execute the actual tool, forwarding onUpdate for progress streaming - try { - const result = await tool.execute(toolCallId, params, signal, onUpdate); - - // Emit tool_result event - hooks can modify the result - if (hookRunner.hasHandlers("tool_result")) { - const resultResult = (await hookRunner.emit({ - type: "tool_result", - toolName: tool.name, - toolCallId, - input: params, - content: result.content, - details: result.details, - isError: false, - })) as ToolResultEventResult | undefined; - - // Apply modifications if any - if (resultResult) { - return { - content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, - }; - } - } - - return result; - } catch (err) { - // Emit tool_result event for errors so hooks can observe failures - if (hookRunner.hasHandlers("tool_result")) { - await hookRunner.emit({ - type: "tool_result", - toolName: tool.name, - toolCallId, - input: params, - content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], - details: undefined, - isError: true, - }); - } - throw err; // Re-throw original error for agent-loop - } - }, - }; -} - -/** - * Wrap all tools with hook callbacks. - */ -export function wrapToolsWithHooks(tools: AgentTool[], hookRunner: HookRunner): AgentTool[] { - return tools.map((tool) => wrapToolWithHooks(tool, hookRunner)); -} diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts deleted file mode 100644 index 76d4f81d..00000000 --- a/packages/coding-agent/src/core/hooks/types.ts +++ /dev/null @@ -1,942 +0,0 @@ -/** - * Hook system types. - * - * Hooks are TypeScript modules that can subscribe to agent lifecycle events - * and interact with the user via UI primitives. - */ - -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { Component, KeyId, TUI } from "@mariozechner/pi-tui"; -import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; -import type { EventBus } from "../event-bus.js"; -import type { ExecOptions, ExecResult } from "../exec.js"; -import type { HookMessage } from "../messages.js"; -import type { ModelRegistry } from "../model-registry.js"; -import type { - BranchSummaryEntry, - CompactionEntry, - ReadonlySessionManager, - SessionEntry, - SessionManager, -} from "../session-manager.js"; - -import type { EditToolDetails } from "../tools/edit.js"; -import type { - BashToolDetails, - FindToolDetails, - GrepToolDetails, - LsToolDetails, - ReadToolDetails, -} from "../tools/index.js"; - -// Re-export for backward compatibility -export type { ExecOptions, ExecResult } from "../exec.js"; - -/** - * UI context for hooks to request interactive UI from the harness. - * Each mode (interactive, RPC, print) provides its own implementation. - */ -export interface HookUIContext { - /** - * Show a selector and return the user's choice. - * @param title - Title to display - * @param options - Array of string options - * @returns Selected option string, or null if cancelled - */ - select(title: string, options: string[]): Promise; - - /** - * Show a confirmation dialog. - * @returns true if confirmed, false if cancelled - */ - confirm(title: string, message: string): Promise; - - /** - * Show a text input dialog. - * @returns User input, or undefined if cancelled - */ - input(title: string, placeholder?: string): Promise; - - /** - * Show a notification to the user. - */ - notify(message: string, type?: "info" | "warning" | "error"): void; - - /** - * Set status text in the footer/status bar. - * Pass undefined as text to clear the status for this key. - * Text can include ANSI escape codes for styling. - * Note: Newlines, tabs, and carriage returns are replaced with spaces. - * The combined status line is truncated to terminal width. - * @param key - Unique key to identify this status (e.g., hook name) - * @param text - Status text to display, or undefined to clear - */ - setStatus(key: string, text: string | undefined): void; - - /** - * Set a widget to display in the status area (above the editor, below "Working..." indicator). - * Supports multi-line content. Pass undefined to clear. - * Text can include ANSI escape codes for styling. - * - * Accepts either an array of styled strings, or a factory function that creates a Component. - * - * @param key - Unique key to identify this widget (e.g., hook name) - * @param content - Array of lines to display, or undefined to clear - * - * @example - * // Show a todo list with styled strings - * ctx.ui.setWidget("plan-todos", [ - * theme.fg("accent", "Plan Progress:"), - * "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")), - * "☐ Step 2: Modify code", - * "☐ Step 3: Run tests", - * ]); - * - * // Clear the widget - * ctx.ui.setWidget("plan-todos", undefined); - */ - setWidget(key: string, content: string[] | undefined): void; - - /** - * Set a custom component as a widget (above the editor, below "Working..." indicator). - * Unlike custom(), this does NOT take keyboard focus - the editor remains focused. - * Pass undefined to clear the widget. - * - * The component should implement render(width) and optionally dispose(). - * Components are rendered inline without taking focus - they cannot handle keyboard input. - * - * @param key - Unique key to identify this widget (e.g., hook name) - * @param content - Factory function that creates the component, or undefined to clear - * - * @example - * // Show a custom progress component - * ctx.ui.setWidget("my-progress", (tui, theme) => { - * return new MyProgressComponent(tui, theme); - * }); - * - * // Clear the widget - * ctx.ui.setWidget("my-progress", undefined); - */ - setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; - - /** - * Set the terminal window/tab title. - * Uses OSC escape sequence (works in most modern terminals). - * @param title - Title text to display - */ - setTitle(title: string): void; - - /** - * Show a custom component with keyboard focus. - * The factory receives TUI, theme, and a done() callback to close the component. - * Can be async for fire-and-forget work (don't await the work, just start it). - * - * @param factory - Function that creates the component. Call done() when finished. - * @returns Promise that resolves with the value passed to done() - * - * @example - * // Sync factory - * const result = await ctx.ui.custom((tui, theme, done) => { - * const component = new MyComponent(tui, theme); - * component.onFinish = (value) => done(value); - * return component; - * }); - * - * // Async factory with fire-and-forget work - * const result = await ctx.ui.custom(async (tui, theme, done) => { - * const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working..."); - * loader.onAbort = () => done(null); - * doWork(loader.signal).then(done); // Don't await - fire and forget - * return loader; - * }); - */ - custom( - factory: ( - tui: TUI, - theme: Theme, - done: (result: T) => void, - ) => (Component & { dispose?(): void }) | Promise, - ): Promise; - - /** - * Set the text in the core input editor. - * Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions). - * @param text - Text to set in the editor - */ - setEditorText(text: string): void; - - /** - * Get the current text from the core input editor. - * @returns Current editor text - */ - getEditorText(): string; - - /** - * Show a multi-line editor for text editing. - * Supports Ctrl+G to open external editor ($VISUAL or $EDITOR). - * @param title - Title describing what is being edited - * @param prefill - Optional initial text - * @returns Edited text, or undefined if cancelled (Escape) - */ - editor(title: string, prefill?: string): Promise; - - /** - * Get the current theme for styling text with ANSI codes. - * Use theme.fg() and theme.bg() to style status text. - * - * @example - * const theme = ctx.ui.theme; - * ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + " Ready"); - */ - readonly theme: Theme; -} - -/** - * Context passed to hook event handlers. - * For command handlers, see HookCommandContext which extends this with session control methods. - */ -export interface HookContext { - /** UI methods for user interaction */ - ui: HookUIContext; - /** Whether UI is available (false in print mode) */ - hasUI: boolean; - /** Current working directory */ - cwd: string; - /** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */ - sessionManager: ReadonlySessionManager; - /** Model registry - use for API key resolution and model retrieval */ - modelRegistry: ModelRegistry; - /** Current model (may be undefined if no model is selected yet) */ - model: Model | undefined; - /** Whether the agent is idle (not streaming) */ - isIdle(): boolean; - /** Abort the current agent operation (fire-and-forget, does not wait) */ - abort(): void; - /** Whether there are queued messages waiting to be processed */ - hasPendingMessages(): boolean; -} - -/** - * Extended context for slash command handlers. - * Includes session control methods that are only safe in user-initiated commands. - * - * These methods are not available in event handlers because they can cause - * deadlocks when called from within the agent loop (e.g., tool_call, context events). - */ -export interface HookCommandContext extends HookContext { - /** Wait for the agent to finish streaming */ - waitForIdle(): Promise; - - /** - * Start a new session, optionally with a setup callback to initialize it. - * The setup callback receives a writable SessionManager for the new session. - * - * @param options.parentSession - Path to parent session for lineage tracking - * @param options.setup - Async callback to initialize the new session (e.g., append messages) - * @returns Object with `cancelled: true` if a hook cancelled the new session - * - * @example - * // Handoff: summarize current session and start fresh with context - * await ctx.newSession({ - * parentSession: ctx.sessionManager.getSessionFile(), - * setup: async (sm) => { - * sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] }); - * } - * }); - */ - newSession(options?: { - parentSession?: string; - setup?: (sessionManager: SessionManager) => Promise; - }): Promise<{ cancelled: boolean }>; - - /** - * Branch from a specific entry, creating a new session file. - * - * @param entryId - ID of the entry to branch from - * @returns Object with `cancelled: true` if a hook cancelled the branch - */ - branch(entryId: string): Promise<{ cancelled: boolean }>; - - /** - * Navigate to a different point in the session tree (in-place). - * - * @param targetId - ID of the entry to navigate to - * @param options.summarize - Whether to summarize the abandoned branch - * @returns Object with `cancelled: true` if a hook cancelled the navigation - */ - navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; -} - -// ============================================================================ -// Session Events -// ============================================================================ - -/** Fired on initial session load */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** Fired before switching to another session (can be cancelled) */ -export interface SessionBeforeSwitchEvent { - type: "session_before_switch"; - /** Reason for the switch */ - reason: "new" | "resume"; - /** Session file we're switching to (only for "resume") */ - targetSessionFile?: string; -} - -/** Fired after switching to another session */ -export interface SessionSwitchEvent { - type: "session_switch"; - /** Reason for the switch */ - reason: "new" | "resume"; - /** Session file we came from */ - previousSessionFile: string | undefined; -} - -/** Fired before branching a session (can be cancelled) */ -export interface SessionBeforeBranchEvent { - type: "session_before_branch"; - /** ID of the entry to branch from */ - entryId: string; -} - -/** Fired after branching a session */ -export interface SessionBranchEvent { - type: "session_branch"; - previousSessionFile: string | undefined; -} - -/** Fired before context compaction (can be cancelled or customized) */ -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ - preparation: CompactionPreparation; - /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ - branchEntries: SessionEntry[]; - /** Optional user-provided instructions for the summary */ - customInstructions?: string; - /** Abort signal - hooks should pass this to LLM calls and check it periodically */ - signal: AbortSignal; -} - -/** Fired after context compaction */ -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - /** Whether the compaction entry was provided by a hook */ - fromHook: boolean; -} - -/** Fired on process exit (SIGINT/SIGTERM) */ -export interface SessionShutdownEvent { - type: "session_shutdown"; -} - -/** Preparation data for tree navigation (used by session_before_tree event) */ -export interface TreePreparation { - /** Node being switched to */ - targetId: string; - /** Current active leaf (being abandoned), null if no current position */ - oldLeafId: string | null; - /** Common ancestor of target and old leaf, null if no common ancestor */ - commonAncestorId: string | null; - /** Entries to summarize (old leaf back to common ancestor or compaction) */ - entriesToSummarize: SessionEntry[]; - /** Whether user chose to summarize */ - userWantsSummary: boolean; -} - -/** Fired before navigating to a different node in the session tree (can be cancelled) */ -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - /** Preparation data for the navigation */ - preparation: TreePreparation; - /** Abort signal - honors Escape during summarization (model available via ctx.model) */ - signal: AbortSignal; -} - -/** Fired after navigating to a different node in the session tree */ -export interface SessionTreeEvent { - type: "session_tree"; - /** The new active leaf, null if navigated to before first entry */ - newLeafId: string | null; - /** Previous active leaf, null if there was no position */ - oldLeafId: string | null; - /** Branch summary entry if one was created */ - summaryEntry?: BranchSummaryEntry; - /** Whether summary came from hook */ - fromHook?: boolean; -} - -/** Union of all session event types */ -export type SessionEvent = - | SessionStartEvent - | SessionBeforeSwitchEvent - | SessionSwitchEvent - | SessionBeforeBranchEvent - | SessionBranchEvent - | SessionBeforeCompactEvent - | SessionCompactEvent - | SessionShutdownEvent - | SessionBeforeTreeEvent - | SessionTreeEvent; - -/** - * Event data for context event. - * Fired before each LLM call, allowing hooks to modify context non-destructively. - * Original session messages are NOT modified - only the messages sent to the LLM are affected. - */ -export interface ContextEvent { - type: "context"; - /** Messages about to be sent to the LLM (deep copy, safe to modify) */ - messages: AgentMessage[]; -} - -/** - * Event data for before_agent_start event. - * Fired after user submits a prompt but before the agent loop starts. - * Allows hooks to inject context that will be persisted and visible in TUI. - */ -export interface BeforeAgentStartEvent { - type: "before_agent_start"; - /** The user's prompt text */ - prompt: string; - /** Any images attached to the prompt */ - images?: ImageContent[]; -} - -/** - * Event data for agent_start event. - * Fired when an agent loop starts (once per user prompt). - */ -export interface AgentStartEvent { - type: "agent_start"; -} - -/** - * Event data for agent_end event. - */ -export interface AgentEndEvent { - type: "agent_end"; - messages: AgentMessage[]; -} - -/** - * Event data for turn_start event. - */ -export interface TurnStartEvent { - type: "turn_start"; - turnIndex: number; - timestamp: number; -} - -/** - * Event data for turn_end event. - */ -export interface TurnEndEvent { - type: "turn_end"; - turnIndex: number; - message: AgentMessage; - toolResults: ToolResultMessage[]; -} - -/** - * Event data for tool_call event. - * Fired before a tool is executed. Hooks can block execution. - */ -export interface ToolCallEvent { - type: "tool_call"; - /** Tool name (e.g., "bash", "edit", "write") */ - toolName: string; - /** Tool call ID */ - toolCallId: string; - /** Tool input parameters */ - input: Record; -} - -/** - * Base interface for tool_result events. - */ -interface ToolResultEventBase { - type: "tool_result"; - /** Tool call ID */ - toolCallId: string; - /** Tool input parameters */ - input: Record; - /** Full content array (text and images) */ - content: (TextContent | ImageContent)[]; - /** Whether the tool execution was an error */ - isError: boolean; -} - -/** Tool result event for bash tool */ -export interface BashToolResultEvent extends ToolResultEventBase { - toolName: "bash"; - details: BashToolDetails | undefined; -} - -/** Tool result event for read tool */ -export interface ReadToolResultEvent extends ToolResultEventBase { - toolName: "read"; - details: ReadToolDetails | undefined; -} - -/** Tool result event for edit tool */ -export interface EditToolResultEvent extends ToolResultEventBase { - toolName: "edit"; - details: EditToolDetails | undefined; -} - -/** Tool result event for write tool */ -export interface WriteToolResultEvent extends ToolResultEventBase { - toolName: "write"; - details: undefined; -} - -/** Tool result event for grep tool */ -export interface GrepToolResultEvent extends ToolResultEventBase { - toolName: "grep"; - details: GrepToolDetails | undefined; -} - -/** Tool result event for find tool */ -export interface FindToolResultEvent extends ToolResultEventBase { - toolName: "find"; - details: FindToolDetails | undefined; -} - -/** Tool result event for ls tool */ -export interface LsToolResultEvent extends ToolResultEventBase { - toolName: "ls"; - details: LsToolDetails | undefined; -} - -/** Tool result event for custom/unknown tools */ -export interface CustomToolResultEvent extends ToolResultEventBase { - toolName: string; - details: unknown; -} - -/** - * Event data for tool_result event. - * Fired after a tool is executed. Hooks can modify the result. - * Use toolName to discriminate and get typed details. - */ -export type ToolResultEvent = - | BashToolResultEvent - | ReadToolResultEvent - | EditToolResultEvent - | WriteToolResultEvent - | GrepToolResultEvent - | FindToolResultEvent - | LsToolResultEvent - | CustomToolResultEvent; - -// Type guards for narrowing ToolResultEvent to specific tool types -export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { - return e.toolName === "bash"; -} -export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { - return e.toolName === "read"; -} -export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent { - return e.toolName === "edit"; -} -export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent { - return e.toolName === "write"; -} -export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent { - return e.toolName === "grep"; -} -export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent { - return e.toolName === "find"; -} -export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { - return e.toolName === "ls"; -} - -/** - * Union of all hook event types. - */ -export type HookEvent = - | SessionEvent - | ContextEvent - | BeforeAgentStartEvent - | AgentStartEvent - | AgentEndEvent - | TurnStartEvent - | TurnEndEvent - | ToolCallEvent - | ToolResultEvent; - -// ============================================================================ -// Event Results -// ============================================================================ - -/** - * Return type for context event handlers. - * Allows hooks to modify messages before they're sent to the LLM. - */ -export interface ContextEventResult { - /** Modified messages to send instead of the original */ - messages?: AgentMessage[]; -} - -/** - * Return type for tool_call event handlers. - * Allows hooks to block tool execution. - */ -export interface ToolCallEventResult { - /** If true, block the tool from executing */ - block?: boolean; - /** Reason for blocking (returned to LLM as error) */ - reason?: string; -} - -/** - * Return type for tool_result event handlers. - * Allows hooks to modify tool results. - */ -export interface ToolResultEventResult { - /** Replacement content array (text and images) */ - content?: (TextContent | ImageContent)[]; - /** Replacement details */ - details?: unknown; - /** Override isError flag */ - isError?: boolean; -} - -/** - * Return type for before_agent_start event handlers. - * Allows hooks to inject context before the agent runs. - */ -export interface BeforeAgentStartEventResult { - /** Message to inject into context (persisted to session, visible in TUI) */ - message?: Pick; - /** Text to append to the system prompt for this agent run */ - systemPromptAppend?: string; -} - -/** Return type for session_before_switch handlers */ -export interface SessionBeforeSwitchResult { - /** If true, cancel the switch */ - cancel?: boolean; -} - -/** Return type for session_before_branch handlers */ -export interface SessionBeforeBranchResult { - /** - * If true, abort the branch entirely. No new session file is created, - * conversation stays unchanged. - */ - cancel?: boolean; - /** - * If true, the branch proceeds (new session file created, session state updated) - * but the in-memory conversation is NOT rewound to the branch point. - * - * Use case: git-checkpoint hook that restores code state separately. - * The hook handles state restoration itself, so it doesn't want the - * agent's conversation to be rewound (which would lose recent context). - * - * - `cancel: true` → nothing happens, user stays in current session - * - `skipConversationRestore: true` → branch happens, but messages stay as-is - * - neither → branch happens AND messages rewind to branch point (default) - */ - skipConversationRestore?: boolean; -} - -/** Return type for session_before_compact handlers */ -export interface SessionBeforeCompactResult { - /** If true, cancel the compaction */ - cancel?: boolean; - /** Custom compaction result - SessionManager adds id/parentId */ - compaction?: CompactionResult; -} - -/** Return type for session_before_tree handlers */ -export interface SessionBeforeTreeResult { - /** If true, cancel the navigation entirely */ - cancel?: boolean; - /** - * Custom summary (skips default summarizer). - * Only used if preparation.userWantsSummary is true. - */ - summary?: { - summary: string; - details?: unknown; - }; -} - -// ============================================================================ -// Hook API -// ============================================================================ - -/** - * Handler function type for each event. - * Handlers can return R, undefined, or void (bare return statements). - */ -// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers -export type HookHandler = (event: E, ctx: HookContext) => Promise | R | void; - -export interface HookMessageRenderOptions { - /** Whether the view is expanded */ - expanded: boolean; -} - -/** - * Renderer for hook messages. - * Hooks register these to provide custom TUI rendering for their message types. - */ -export type HookMessageRenderer = ( - message: HookMessage, - options: HookMessageRenderOptions, - theme: Theme, -) => Component | undefined; - -/** - * Command registration options. - */ -export interface RegisteredCommand { - name: string; - description?: string; - - handler: (args: string, ctx: HookCommandContext) => Promise; -} - -/** - * HookAPI passed to hook factory functions. - * Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages. - */ -export interface HookAPI { - // Session events - on(event: "session_start", handler: HookHandler): void; - on(event: "session_before_switch", handler: HookHandler): void; - on(event: "session_switch", handler: HookHandler): void; - on(event: "session_before_branch", handler: HookHandler): void; - on(event: "session_branch", handler: HookHandler): void; - on( - event: "session_before_compact", - handler: HookHandler, - ): void; - on(event: "session_compact", handler: HookHandler): void; - on(event: "session_shutdown", handler: HookHandler): void; - on(event: "session_before_tree", handler: HookHandler): void; - on(event: "session_tree", handler: HookHandler): void; - - // Context and agent events - on(event: "context", handler: HookHandler): void; - on(event: "before_agent_start", handler: HookHandler): void; - on(event: "agent_start", handler: HookHandler): void; - on(event: "agent_end", handler: HookHandler): void; - on(event: "turn_start", handler: HookHandler): void; - on(event: "turn_end", handler: HookHandler): void; - on(event: "tool_call", handler: HookHandler): void; - on(event: "tool_result", handler: HookHandler): void; - - /** - * Send a custom message to the session. Creates a CustomMessageEntry that - * participates in LLM context and can be displayed in the TUI. - * - * Use this when you want the LLM to see the message content. - * For hook state that should NOT be sent to the LLM, use appendEntry() instead. - * - * @param message - The message to send - * @param message.customType - Identifier for your hook (used for filtering on reload) - * @param message.content - Message content (string or TextContent/ImageContent array) - * @param message.display - Whether to show in TUI (true = styled display, false = hidden) - * @param message.details - Optional hook-specific metadata (not sent to LLM) - * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. - * Required for async patterns where you want the agent to respond. - * If agent is streaming, message is queued and triggerTurn is ignored. - * @param options.deliverAs - How to deliver the message. Default: "steer". - * - "steer": (streaming) Interrupt mid-run, delivered after current tool execution. - * - "followUp": (streaming) Wait until agent finishes all work before delivery. - * - "nextTurn": (idle) Queue to be included with the next user message as context. - * The message becomes an "aside" - context for the next turn without - * triggering a turn or appearing as a standalone entry. - */ - sendMessage( - message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, - ): void; - - /** - * Append a custom entry to the session for hook state persistence. - * Creates a CustomEntry that does NOT participate in LLM context. - * - * Use this to store hook-specific data that should persist across session reloads - * but should NOT be sent to the LLM. On reload, scan session entries for your - * customType to reconstruct hook state. - * - * For messages that SHOULD be sent to the LLM, use sendMessage() instead. - * - * @param customType - Identifier for your hook (used for filtering on reload) - * @param data - Hook-specific data to persist (must be JSON-serializable) - * - * @example - * // Store permission state - * pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() }); - * - * // On reload, reconstruct state from entries - * pi.on("session", async (event, ctx) => { - * if (event.reason === "start") { - * const entries = event.sessionManager.getEntries(); - * const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions"); - * // Reconstruct state from myEntries... - * } - * }); - */ - appendEntry(customType: string, data?: T): void; - - /** - * Register a custom renderer for CustomMessageEntry with a specific customType. - * The renderer is called when rendering the entry in the TUI. - * Return nothing to use the default renderer. - */ - registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void; - - /** - * Register a custom slash command. - * Handler receives HookCommandContext with session control methods. - */ - registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; - - /** - * Execute a shell command and return stdout/stderr/code. - * Supports timeout and abort signal. - */ - exec(command: string, args: string[], options?: ExecOptions): Promise; - - /** - * Get the list of currently active tool names. - * @returns Array of tool names (e.g., ["read", "bash", "edit", "write"]) - */ - getActiveTools(): string[]; - - /** - * Get all configured tools (built-in via --tools or default, plus custom tools). - * @returns Array of all tool names - */ - getAllTools(): string[]; - - /** - * Set the active tools by name. - * Both built-in and custom tools can be enabled/disabled. - * Changes take effect on the next agent turn. - * Note: This will invalidate prompt caching for the next request. - * - * @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"]) - * - * @example - * // Switch to read-only mode (plan mode) - * pi.setActiveTools(["read", "bash", "grep", "find", "ls"]); - * - * // Restore full access - * pi.setActiveTools(["read", "bash", "edit", "write"]); - */ - setActiveTools(toolNames: string[]): void; - - /** - * Register a CLI flag for this hook. - * Flags are parsed from command line and values accessible via getFlag(). - * - * @param name - Flag name (will be --name on CLI) - * @param options - Flag configuration - * - * @example - * pi.registerFlag("plan", { - * description: "Start in plan mode (read-only)", - * type: "boolean", - * }); - */ - registerFlag( - name: string, - options: { - /** Description shown in --help */ - description?: string; - /** Flag type: boolean (--flag) or string (--flag value) */ - type: "boolean" | "string"; - /** Default value */ - default?: boolean | string; - }, - ): void; - - /** - * Get the value of a CLI flag registered by this hook. - * Returns undefined if flag was not provided and has no default. - * - * @param name - Flag name (without --) - * @returns Flag value, or undefined - * - * @example - * if (pi.getFlag("plan")) { - * // plan mode enabled - * } - */ - getFlag(name: string): boolean | string | undefined; - - /** - * Register a keyboard shortcut for this hook. - * The handler is called when the shortcut is pressed in interactive mode. - * - * @param shortcut - Key identifier (e.g., Key.shift("p"), "ctrl+x") - * @param options - Shortcut configuration - * - * @example - * import { Key } from "@mariozechner/pi-tui"; - * - * pi.registerShortcut(Key.shift("p"), { - * description: "Toggle plan mode", - * handler: async (ctx) => { - * // toggle plan mode - * }, - * }); - */ - registerShortcut( - shortcut: KeyId, - options: { - /** Description shown in help */ - description?: string; - /** Handler called when shortcut is pressed */ - handler: (ctx: HookContext) => Promise | void; - }, - ): void; - - /** - * Shared event bus for tool/hook communication. - * Tools can emit events, hooks can listen for them. - * - * @example - * // Hook listening for events - * pi.events.on("subagent:complete", (data) => { - * pi.sendMessage({ customType: "notify", content: `Done: ${data.summary}` }); - * }); - * - * // Tool emitting events (in custom tool) - * pi.events.emit("my:event", { status: "complete" }); - */ - events: EventBus; -} - -/** - * Hook factory function type. - * Hooks export a default function that receives the HookAPI. - */ -export type HookFactory = (pi: HookAPI) => void; - -// ============================================================================ -// Errors -// ============================================================================ - -/** - * Error emitted when a hook fails. - */ -export interface HookError { - hookPath: string; - event: string; - error: string; - stack?: string; -} diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 675561d5..e0b95f4c 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -13,26 +13,49 @@ export { } from "./agent-session.js"; export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; export type { CompactionResult } from "./compaction/index.js"; -export { - type CustomTool, - type CustomToolAPI, - type CustomToolFactory, - type CustomToolsLoadResult, - type CustomToolUIContext, - discoverAndLoadCustomTools, - type ExecResult, - type LoadedCustomTool, - loadCustomTools, - type RenderResultOptions, -} from "./custom-tools/index.js"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; + +// Extensions system export { - type HookAPI, - type HookContext, - type HookError, - type HookEvent, - type HookFactory, - HookRunner, - type HookUIContext, - loadHooks, -} from "./hooks/index.js"; + type AgentEndEvent, + type AgentStartEvent, + type AgentToolResult, + type AgentToolUpdateCallback, + type BeforeAgentStartEvent, + type ContextEvent, + discoverAndLoadExtensions, + type ExecOptions, + type ExecResult, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, + type ExtensionError, + type ExtensionEvent, + type ExtensionFactory, + type ExtensionFlag, + type ExtensionHandler, + ExtensionRunner, + type ExtensionShortcut, + type ExtensionUIContext, + type LoadExtensionsResult, + type LoadedExtension, + type MessageRenderer, + type RegisteredCommand, + type SessionBeforeBranchEvent, + type SessionBeforeCompactEvent, + type SessionBeforeSwitchEvent, + type SessionBeforeTreeEvent, + type SessionBranchEvent, + type SessionCompactEvent, + type SessionShutdownEvent, + type SessionStartEvent, + type SessionSwitchEvent, + type SessionTreeEvent, + type ToolCallEvent, + type ToolDefinition, + type ToolRenderResultOptions, + type ToolResultEvent, + type TurnEndEvent, + type TurnStartEvent, + wrapToolsWithExtensions, +} from "./extensions/index.js"; diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 87c37fac..f5a645e6 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -40,11 +40,11 @@ export interface BashExecutionMessage { } /** - * Message type for hook-injected messages via sendMessage(). - * These are custom messages that hooks can inject into the conversation. + * Message type for extension-injected messages via sendMessage(). + * These are custom messages that extensions can inject into the conversation. */ -export interface HookMessage { - role: "hookMessage"; +export interface CustomMessage { + role: "custom"; customType: string; content: string | (TextContent | ImageContent)[]; display: boolean; @@ -70,7 +70,7 @@ export interface CompactionSummaryMessage { declare module "@mariozechner/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; - hookMessage: HookMessage; + custom: CustomMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; } @@ -120,15 +120,15 @@ export function createCompactionSummaryMessage( } /** Convert CustomMessageEntry to AgentMessage format */ -export function createHookMessage( +export function createCustomMessage( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string, -): HookMessage { +): CustomMessage { return { - role: "hookMessage", + role: "custom", customType, content, display, @@ -143,7 +143,7 @@ export function createHookMessage( * This is used by: * - Agent's transormToLlm option (for prompt calls and queued messages) * - Compaction's generateSummary (for summarization) - * - Custom hooks and tools + * - Custom extensions and tools */ export function convertToLlm(messages: AgentMessage[]): Message[] { return messages @@ -159,7 +159,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp, }; - case "hookMessage": { + case "custom": { const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; return { role: "user", diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/prompt-templates.ts similarity index 70% rename from packages/coding-agent/src/core/slash-commands.ts rename to packages/coding-agent/src/core/prompt-templates.ts index 1aec734f..08548e00 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,11 +1,11 @@ import { existsSync, readdirSync, readFileSync } from "fs"; import { join, resolve } from "path"; -import { CONFIG_DIR_NAME, getCommandsDir } from "../config.js"; +import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; /** - * Represents a custom slash command loaded from a file + * Represents a prompt template loaded from a markdown file */ -export interface FileSlashCommand { +export interface PromptTemplate { name: string; description: string; content: string; @@ -80,7 +80,7 @@ export function parseCommandArgs(argsString: string): string[] { } /** - * Substitute argument placeholders in command content + * Substitute argument placeholders in template content * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args * * Note: Replacement happens on the template string only. Argument values @@ -109,13 +109,13 @@ export function substituteArgs(content: string, args: string[]): string { } /** - * Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands + * Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates */ -function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] { - const commands: FileSlashCommand[] = []; +function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] { + const templates: PromptTemplate[] = []; if (!existsSync(dir)) { - return commands; + return templates; } try { @@ -127,7 +127,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st if (entry.isDirectory()) { // Recurse into subdirectory const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name; - commands.push(...loadCommandsFromDir(fullPath, source, newSubdir)); + templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir)); } else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) { try { const rawContent = readFileSync(fullPath, "utf-8"); @@ -157,7 +157,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st // Append source to description description = description ? `${description} ${sourceStr}` : sourceStr; - commands.push({ + templates.push({ name, description, content, @@ -172,54 +172,54 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st // Silently skip directories that can't be read } - return commands; + return templates; } -export interface LoadSlashCommandsOptions { - /** Working directory for project-local commands. Default: process.cwd() */ +export interface LoadPromptTemplatesOptions { + /** Working directory for project-local templates. Default: process.cwd() */ cwd?: string; - /** Agent config directory for global commands. Default: from getCommandsDir() */ + /** Agent config directory for global templates. Default: from getPromptsDir() */ agentDir?: string; } /** - * Load all custom slash commands from: - * 1. Global: agentDir/commands/ - * 2. Project: cwd/{CONFIG_DIR_NAME}/commands/ + * Load all prompt templates from: + * 1. Global: agentDir/prompts/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ */ -export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] { +export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] { const resolvedCwd = options.cwd ?? process.cwd(); - const resolvedAgentDir = options.agentDir ?? getCommandsDir(); + const resolvedAgentDir = options.agentDir ?? getPromptsDir(); - const commands: FileSlashCommand[] = []; + const templates: PromptTemplate[] = []; - // 1. Load global commands from agentDir/commands/ - // Note: if agentDir is provided, it should be the agent dir, not the commands dir - const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir; - commands.push(...loadCommandsFromDir(globalCommandsDir, "user")); + // 1. Load global templates from agentDir/prompts/ + // Note: if agentDir is provided, it should be the agent dir, not the prompts dir + const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + templates.push(...loadTemplatesFromDir(globalPromptsDir, "user")); - // 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/ - const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands"); - commands.push(...loadCommandsFromDir(projectCommandsDir, "project")); + // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ + const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + templates.push(...loadTemplatesFromDir(projectPromptsDir, "project")); - return commands; + return templates; } /** - * Expand a slash command if it matches a file-based command. - * Returns the expanded content or the original text if not a slash command. + * Expand a prompt template if it matches a template name. + * Returns the expanded content or the original text if not a template. */ -export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string { +export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { if (!text.startsWith("/")) return text; const spaceIndex = text.indexOf(" "); - const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); - const fileCommand = fileCommands.find((cmd) => cmd.name === commandName); - if (fileCommand) { + const template = templates.find((t) => t.name === templateName); + if (template) { const args = parseCommandArgs(argsString); - return substituteArgs(fileCommand.content, args); + return substituteArgs(template.content, args); } return text; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5f21348a..ffd0f7ab 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -9,20 +9,11 @@ * // Minimal - everything auto-discovered * const session = await createAgentSession(); * - * // With custom hooks - * const session = await createAgentSession({ - * hooks: [ - * ...await discoverHooks(), - * { factory: myHookFactory }, - * ], - * }); - * * // Full control * const session = await createAgentSession({ * model: myModel, * getApiKey: async () => process.env.MY_KEY, * tools: [readTool, bashTool], - * hooks: [], * skills: [], * sessionFile: false, * }); @@ -31,27 +22,25 @@ import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import type { KeyId } from "@mariozechner/pi-tui"; import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; -import { - type CustomToolsLoadResult, - discoverAndLoadCustomTools, - type LoadedCustomTool, - wrapCustomTools, -} from "./custom-tools/index.js"; -import type { CustomTool } from "./custom-tools/types.js"; import { createEventBus, type EventBus } from "./event-bus.js"; -import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; -import type { HookFactory } from "./hooks/types.js"; +import { + discoverAndLoadExtensions, + ExtensionRunner, + type LoadExtensionsResult, + type LoadedExtension, + wrapRegisteredTools, + wrapToolsWithExtensions, +} from "./extensions/index.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; +import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js"; import { SessionManager } from "./session-manager.js"; import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js"; -import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js"; import { buildSystemPrompt as buildSystemPromptInternal, loadProjectContextFiles as loadContextFilesInternal, @@ -107,27 +96,20 @@ export interface CreateAgentSessionOptions { /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; - /** Custom tools (replaces discovery). */ - customTools?: Array<{ path?: string; tool: CustomTool }>; - /** Additional custom tool paths to load (merged with discovery). */ - additionalCustomToolPaths?: string[]; + /** Additional extension paths to load (merged with discovery). */ + additionalExtensionPaths?: string[]; + /** Pre-loaded extensions (skips loading, used when extensions were loaded early for CLI flags). */ + preloadedExtensions?: LoadedExtension[]; - /** Hooks (replaces discovery). */ - hooks?: Array<{ path?: string; factory: HookFactory }>; - /** Additional hook paths to load (merged with discovery). */ - additionalHookPaths?: string[]; - /** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */ - preloadedHooks?: LoadedHook[]; - - /** Shared event bus for tool/hook communication. Default: creates new bus. */ + /** Shared event bus for tool/extension communication. Default: creates new bus. */ eventBus?: EventBus; /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ contextFiles?: Array<{ path: string; content: string }>; - /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ - slashCommands?: FileSlashCommand[]; + /** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */ + promptTemplates?: PromptTemplate[]; /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; @@ -140,19 +122,18 @@ export interface CreateAgentSessionOptions { export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; - /** Custom tools result (for UI context setup in interactive mode) */ - customToolsResult: CustomToolsLoadResult; + /** Extensions result (for UI context setup in interactive mode) */ + extensionsResult: LoadExtensionsResult; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; } // Re-exports -export type { CustomTool } from "./custom-tools/types.js"; -export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js"; +export type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "./extensions/index.js"; +export type { PromptTemplate } from "./prompt-templates.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; -export type { FileSlashCommand } from "./slash-commands.js"; export type { Tool } from "./tools/index.js"; export { @@ -202,63 +183,27 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD } /** - * Discover hooks from cwd and agentDir. - * @param eventBus - Shared event bus for pi.events communication. Pass to createAgentSession too. + * Discover extensions from cwd and agentDir. + * @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too. * @param cwd - Current working directory * @param agentDir - Agent configuration directory */ -export async function discoverHooks( +export async function discoverExtensions( eventBus: EventBus, cwd?: string, agentDir?: string, -): Promise> { +): Promise { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); - const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir, eventBus); + const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus); // Log errors but don't fail - for (const { path, error } of errors) { - console.error(`Failed to load hook "${path}": ${error}`); + for (const { path, error } of result.errors) { + console.error(`Failed to load extension "${path}": ${error}`); } - return hooks.map((h) => ({ - path: h.path, - factory: createFactoryFromLoadedHook(h), - })); -} - -/** - * Discover custom tools from cwd and agentDir. - * @param eventBus - Shared event bus for tool.events communication. Pass to createAgentSession too. - * @param cwd - Current working directory - * @param agentDir - Agent configuration directory - */ -export async function discoverCustomTools( - eventBus: EventBus, - cwd?: string, - agentDir?: string, -): Promise> { - const resolvedCwd = cwd ?? process.cwd(); - const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); - - const { tools, errors } = await discoverAndLoadCustomTools( - [], - resolvedCwd, - Object.keys(allTools), - resolvedAgentDir, - eventBus, - ); - - // Log errors but don't fail - for (const { path, error } of errors) { - console.error(`Failed to load custom tool "${path}": ${error}`); - } - - return tools.map((t) => ({ - path: t.path, - tool: t.tool, - })); + return result; } /** @@ -284,10 +229,10 @@ export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ p } /** - * Discover slash commands from cwd and agentDir. + * Discover prompt templates from cwd and agentDir. */ -export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] { - return loadSlashCommandsInternal({ +export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] { + return loadPromptTemplatesInternal({ cwd: cwd ?? process.cwd(), agentDir: agentDir ?? getDefaultAgentDir(), }); @@ -336,139 +281,12 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { hideThinkingBlock: manager.getHideThinkingBlock(), shellPath: manager.getShellPath(), collapseChangelog: manager.getCollapseChangelog(), - hooks: manager.getHookPaths(), - customTools: manager.getCustomToolPaths(), + extensions: manager.getExtensionPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, }; } -// Internal Helpers - -/** - * Create a HookFactory from a LoadedHook. - * This allows mixing discovered hooks with inline hooks. - */ -function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { - return (api) => { - for (const [eventType, handlers] of loaded.handlers) { - for (const handler of handlers) { - api.on(eventType as any, handler as any); - } - } - }; -} - -/** - * Convert hook definitions to LoadedHooks for the HookRunner. - */ -function createLoadedHooksFromDefinitions( - definitions: Array<{ path?: string; factory: HookFactory }>, - eventBus: EventBus, -): LoadedHook[] { - return definitions.map((def) => { - const hookPath = def.path ?? ""; - const handlers = new Map Promise>>(); - const messageRenderers = new Map(); - const commands = new Map(); - const flags = new Map(); - const flagValues = new Map(); - const shortcuts = new Map(); - let sendMessageHandler: ( - message: any, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, - ) => void = () => {}; - let appendEntryHandler: (customType: string, data?: any) => void = () => {}; - let getActiveToolsHandler: () => string[] = () => []; - let getAllToolsHandler: () => string[] = () => []; - let setActiveToolsHandler: (toolNames: string[]) => void = () => {}; - let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); - let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); - let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ - cancelled: false, - }); - - const api = { - on: (event: string, handler: (...args: unknown[]) => Promise) => { - const list = handlers.get(event) ?? []; - list.push(handler); - handlers.set(event, list); - }, - sendMessage: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => { - sendMessageHandler(message, options); - }, - appendEntry: (customType: string, data?: any) => { - appendEntryHandler(customType, data); - }, - registerMessageRenderer: (customType: string, renderer: any) => { - messageRenderers.set(customType, renderer); - }, - registerCommand: (name: string, options: any) => { - commands.set(name, { name, ...options }); - }, - registerFlag: (name: string, options: any) => { - flags.set(name, { name, hookPath, ...options }); - if (options.default !== undefined) { - flagValues.set(name, options.default); - } - }, - getFlag: (name: string) => flagValues.get(name), - registerShortcut: (shortcut: KeyId, options: any) => { - shortcuts.set(shortcut, { shortcut, hookPath, ...options }); - }, - newSession: (options?: any) => newSessionHandler(options), - branch: (entryId: string) => branchHandler(entryId), - navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), - getActiveTools: () => getActiveToolsHandler(), - getAllTools: () => getAllToolsHandler(), - setActiveTools: (toolNames: string[]) => setActiveToolsHandler(toolNames), - events: eventBus, - }; - - def.factory(api as any); - - return { - path: hookPath, - resolvedPath: hookPath, - handlers, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler: ( - handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void, - ) => { - sendMessageHandler = handler; - }, - setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { - appendEntryHandler = handler; - }, - setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => { - newSessionHandler = handler; - }, - setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => { - branchHandler = handler; - }, - setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { - navigateTreeHandler = handler; - }, - setGetActiveToolsHandler: (handler: () => string[]) => { - getActiveToolsHandler = handler; - }, - setGetAllToolsHandler: (handler: () => string[]) => { - getAllToolsHandler = handler; - }, - setSetActiveToolsHandler: (handler: (toolNames: string[]) => void) => { - setActiveToolsHandler = handler; - }, - setFlagValue: (name: string, value: boolean | string) => { - flagValues.set(name, value); - }, - }; - }); -} - // Factory /** @@ -497,7 +315,6 @@ function createLoadedHooksFromDefinitions( * getApiKey: async () => process.env.MY_KEY, * systemPrompt: 'You are helpful.', * tools: [readTool, bashTool], - * hooks: [], * skills: [], * sessionManager: SessionManager.inMemory(), * }); @@ -592,7 +409,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} time("discoverContextFiles"); const autoResizeImages = settingsManager.getImageAutoResize(); - // Create ALL built-in tools for the registry (hooks can enable any of them) + // Create ALL built-in tools for the registry (extensions can enable any of them) const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } }); // Determine initially active built-in tools (default: read, bash, edit, write) const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; @@ -602,62 +419,54 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]); time("createAllTools"); - let customToolsResult: CustomToolsLoadResult; - if (options.customTools !== undefined) { - // Use provided custom tools - const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ - path: ct.path ?? "", - resolvedPath: ct.path ?? "", - tool: ct.tool, - })); - customToolsResult = { - tools: loadedTools, + // Load extensions (discovers from standard locations + configured paths) + let extensionsResult: LoadExtensionsResult; + if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) { + // Use pre-loaded extensions (from early CLI flag discovery) + extensionsResult = { + extensions: options.preloadedExtensions, errors: [], setUIContext: () => {}, - setSendMessageHandler: () => {}, }; } else { - // Discover custom tools, merging with additional paths - const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; - customToolsResult = await discoverAndLoadCustomTools( - configuredPaths, - cwd, - Object.keys(allTools), - agentDir, - eventBus, - ); - time("discoverAndLoadCustomTools"); - for (const { path, error } of customToolsResult.errors) { - console.error(`Failed to load custom tool "${path}": ${error}`); + // Discover extensions, merging with additional paths + const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])]; + extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus); + time("discoverAndLoadExtensions"); + for (const { path, error } of extensionsResult.errors) { + console.error(`Failed to load extension "${path}": ${error}`); } } - let hookRunner: HookRunner | undefined; - if (options.preloadedHooks !== undefined && options.preloadedHooks.length > 0) { - // Use pre-loaded hooks (from early CLI flag discovery) - hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry); - } else if (options.hooks !== undefined) { - if (options.hooks.length > 0) { - const loadedHooks = createLoadedHooksFromDefinitions(options.hooks, eventBus); - hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); - } - } else { - // Discover hooks, merging with additional paths - const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; - const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir, eventBus); - time("discoverAndLoadHooks"); - for (const { path, error } of errors) { - console.error(`Failed to load hook "${path}": ${error}`); - } - if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry); - } + // Create extension runner if we have extensions + let extensionRunner: ExtensionRunner | undefined; + if (extensionsResult.extensions.length > 0) { + extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry); } - // Wrap custom tools with context getter (agent/session assigned below, accessed at execute time) + // Wrap extension-registered tools with context getter (agent/session assigned below, accessed at execute time) let agent: Agent; let session: AgentSession; - const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({ + const registeredTools = extensionRunner?.getAllRegisteredTools() ?? []; + const wrappedExtensionTools = wrapRegisteredTools(registeredTools, () => ({ + ui: extensionRunner?.getUIContext() ?? { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return {} as any; + }, + }, + hasUI: extensionRunner?.getHasUI() ?? false, + cwd, sessionManager, modelRegistry, model: agent.state.model, @@ -668,27 +477,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, })); - // Create tool registry mapping name -> tool (for hook getTools/setTools) - // Registry contains ALL built-in tools so hooks can enable any of them + // Create tool registry mapping name -> tool (for extension getTools/setTools) + // Registry contains ALL built-in tools so extensions can enable any of them const toolRegistry = new Map(); for (const [name, tool] of Object.entries(allBuiltInToolsMap)) { toolRegistry.set(name, tool as AgentTool); } - for (const tool of wrappedCustomTools as AgentTool[]) { + for (const tool of wrappedExtensionTools as AgentTool[]) { toolRegistry.set(tool.name, tool); } - // Initially active tools = active built-in + custom - let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedCustomTools]; + // Initially active tools = active built-in + extension tools + let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedExtensionTools]; time("combineTools"); - // Wrap tools with hooks if available + // Wrap tools with extensions if available let wrappedToolRegistry: Map | undefined; - if (hookRunner) { - activeToolsArray = wrapToolsWithHooks(activeToolsArray as AgentTool[], hookRunner); - // Wrap ALL registry tools (not just active) so hooks can enable any + if (extensionRunner) { + activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner); + // Wrap ALL registry tools (not just active) so extensions can enable any const allRegistryTools = Array.from(toolRegistry.values()); - const wrappedAllTools = wrapToolsWithHooks(allRegistryTools, hookRunner); + const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner); wrappedToolRegistry = new Map(); for (const tool of wrappedAllTools) { wrappedToolRegistry.set(tool.name, tool); @@ -727,8 +536,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const systemPrompt = rebuildSystemPrompt(initialActiveToolNames); time("buildSystemPrompt"); - const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); - time("discoverSlashCommands"); + const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir); + time("discoverPromptTemplates"); agent = new Agent({ initialState: { @@ -738,9 +547,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: activeToolsArray, }, convertToLlm, - transformContext: hookRunner + transformContext: extensionRunner ? async (messages) => { - return hookRunner.emitContext(messages); + return extensionRunner.emitContext(messages); } : undefined, steeringMode: settingsManager.getSteeringMode(), @@ -775,9 +584,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} sessionManager, settingsManager, scopedModels: options.scopedModels, - fileCommands: slashCommands, - hookRunner, - customTools: customToolsResult.tools, + promptTemplates: promptTemplates, + extensionRunner, skillsSettings: settingsManager.getSkillsSettings(), modelRegistry, toolRegistry: wrappedToolRegistry ?? toolRegistry, @@ -785,14 +593,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }); time("createAgentSession"); - // Wire up sendMessage for custom tools - customToolsResult.setSendMessageHandler((msg, opts) => { - session.sendHookMessage(msg, opts); - }); - return { session, - customToolsResult, + extensionsResult, modelFallbackMessage, }; } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 6698ccad..c3f7e0b0 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -17,13 +17,13 @@ import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; import { type BashExecutionMessage, + type CustomMessage, createBranchSummaryMessage, createCompactionSummaryMessage, - createHookMessage, - type HookMessage, + createCustomMessage, } from "./messages.js"; -export const CURRENT_SESSION_VERSION = 2; +export const CURRENT_SESSION_VERSION = 3; export interface SessionHeader { type: "session"; @@ -66,9 +66,9 @@ export interface CompactionEntry extends SessionEntryBase { summary: string; firstKeptEntryId: string; tokensBefore: number; - /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; - /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */ + /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; } @@ -76,17 +76,17 @@ export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; - /** Hook-specific data (not sent to LLM) */ + /** Extension-specific data (not sent to LLM) */ details?: T; - /** True if generated by a hook, false if pi-generated */ + /** True if generated by an extension, false if pi-generated */ fromHook?: boolean; } /** - * Custom entry for hooks to store hook-specific data in the session. - * Use customType to identify your hook's entries. + * Custom entry for extensions to store extension-specific data in the session. + * Use customType to identify your extension's entries. * - * Purpose: Persist hook state across session reloads. On reload, hooks can + * Purpose: Persist extension state across session reloads. On reload, extensions can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). @@ -106,12 +106,12 @@ export interface LabelEntry extends SessionEntryBase { } /** - * Custom message entry for hooks to inject messages into LLM context. - * Use customType to identify your hook's entries. + * Custom message entry for extensions to inject messages into LLM context. + * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). - * Use details for hook-specific metadata (not sent to LLM). + * Use details for extension-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely @@ -218,8 +218,23 @@ function migrateV1ToV2(entries: FileEntry[]): void { } } -// Add future migrations here: -// function migrateV2ToV3(entries: FileEntry[]): void { ... } +/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ +function migrateV2ToV3(entries: FileEntry[]): void { + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 3; + continue; + } + + // Update message entries with hookMessage role + if (entry.type === "message") { + const msgEntry = entry as SessionMessageEntry; + if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") { + (msgEntry.message as { role: string }).role = "custom"; + } + } + } +} /** * Run all necessary migrations to bring entries to current version. @@ -232,7 +247,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean { if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); - // if (version < 3) migrateV2ToV3(entries); + if (version < 3) migrateV2ToV3(entries); return true; } @@ -342,7 +357,7 @@ export function buildSessionContext( messages.push(entry.message); } else if (entry.type === "custom_message") { messages.push( - createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), + createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), ); } else if (entry.type === "branch_summary" && entry.summary) { messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); @@ -609,7 +624,7 @@ export class SessionManager { * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ - appendMessage(message: Message | HookMessage | BashExecutionMessage): string { + appendMessage(message: Message | CustomMessage | BashExecutionMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), @@ -671,7 +686,7 @@ export class SessionManager { return entry.id; } - /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */ + /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType: string, data?: unknown): string { const entry: CustomEntry = { type: "custom", @@ -686,11 +701,11 @@ export class SessionManager { } /** - * Append a custom message entry (for hooks) that participates in LLM context. - * @param customType Hook identifier for filtering on reload + * Append a custom message entry (for extensions) that participates in LLM context. + * @param customType Extension identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) - * @param details Optional hook-specific metadata (not sent to LLM) + * @param details Optional extension-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry( diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ab894dcd..996a804e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -52,8 +52,7 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - hooks?: string[]; // Array of hook file paths - customTools?: string[]; // Array of custom tool file paths + extensions?: string[]; // Array of extension file paths skills?: SkillsSettings; terminal?: TerminalSettings; images?: ImageSettings; @@ -340,21 +339,12 @@ export class SettingsManager { this.save(); } - getHookPaths(): string[] { - return [...(this.settings.hooks ?? [])]; + getExtensionPaths(): string[] { + return [...(this.settings.extensions ?? [])]; } - setHookPaths(paths: string[]): void { - this.globalSettings.hooks = paths; - this.save(); - } - - getCustomToolPaths(): string[] { - return [...(this.settings.customTools ?? [])]; - } - - setCustomToolPaths(paths: string[]): void { - this.globalSettings.customTools = paths; + setExtensionPaths(paths: string[]): void { + this.globalSettings.extensions = paths; this.save(); } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index da9b00da..b4c342d6 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -274,14 +274,16 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin Available tools: ${toolsList} +In addition to the tools above, you may have access to other custom tools depending on the project. + Guidelines: ${guidelines} Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} -- Examples: ${examplesPath} (hooks, custom tools, SDK) -- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) +- Examples: ${examplesPath} (extensions, custom tools, SDK) +- When asked to create: custom models/providers (README.md), extensions (docs/extensions.md, examples/extensions/), themes (docs/theme.md), skills (docs/skills.md) - Always read the doc, examples, AND follow .md cross-references before implementing`; if (appendSection) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index c63497a6..e3630cf6 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -33,25 +33,53 @@ export { serializeConversation, shouldCompact, } from "./core/compaction/index.js"; -// Custom tools -export type { - AgentToolUpdateCallback, - CustomTool, - CustomToolAPI, - CustomToolContext, - CustomToolFactory, - CustomToolSessionEvent, - CustomToolsLoadResult, - CustomToolUIContext, - ExecResult, - LoadedCustomTool, - RenderResultOptions, -} from "./core/custom-tools/index.js"; -export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js"; -export type * from "./core/hooks/index.js"; -// Hook system types and type guards +// Extension system +export type { + AgentEndEvent, + AgentStartEvent, + AgentToolResult, + AgentToolUpdateCallback, + BeforeAgentStartEvent, + ContextEvent, + ExecOptions, + ExecResult, + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + ExtensionShortcut, + ExtensionUIContext, + LoadExtensionsResult, + LoadedExtension, + MessageRenderer, + MessageRenderOptions, + RegisteredCommand, + RegisteredTool, + SessionBeforeBranchEvent, + SessionBeforeCompactEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionBranchEvent, + SessionCompactEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + ToolCallEvent, + ToolDefinition, + ToolRenderResultOptions, + ToolResultEvent, + TurnEndEvent, + TurnStartEvent, +} from "./core/extensions/index.js"; export { + discoverAndLoadExtensions, + ExtensionRunner, isBashToolResult, isEditToolResult, isFindToolResult, @@ -59,7 +87,12 @@ export { isLsToolResult, isReadToolResult, isWriteToolResult, -} from "./core/hooks/index.js"; + loadExtensions, + wrapRegisteredTool, + wrapRegisteredTools, + wrapToolsWithExtensions, + wrapToolWithExtensions, +} from "./core/extensions/index.js"; export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; // SDK for programmatic usage @@ -83,17 +116,12 @@ export { // Discovery discoverAuthStorage, discoverContextFiles, - discoverCustomTools, - discoverHooks, + discoverExtensions, discoverModels, + discoverPromptTemplates, discoverSkills, - discoverSlashCommands, - type FileSlashCommand, - // Hook types - type HookAPI, - type HookContext, - type HookFactory, loadSettings, + type PromptTemplate, // Pre-built tools (use process.cwd()) readOnlyTools, } from "./core/sdk.js"; @@ -159,9 +187,9 @@ export { } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; -// UI components for hooks +// UI components for extensions export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js"; -// Theme utilities for custom tools and hooks +// Theme utilities for custom tools and extensions export { getMarkdownTheme, getSelectListTheme, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 66adbae1..5dbe1d82 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -16,11 +16,9 @@ import { selectSession } from "./cli/session-picker.js"; import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; import type { AgentSession } from "./core/agent-session.js"; -import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { createEventBus } from "./core/event-bus.js"; import { exportFromFile } from "./core/export-html/index.js"; -import { discoverAndLoadHooks } from "./core/hooks/index.js"; -import type { HookUIContext } from "./core/index.js"; +import { discoverAndLoadExtensions, type ExtensionUIContext, type LoadedExtension } from "./core/extensions/index.js"; import type { ModelRegistry } from "./core/model-registry.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js"; @@ -62,13 +60,13 @@ async function runInteractiveMode( migratedProviders: string[], versionCheckPromise: Promise, initialMessages: string[], - customTools: LoadedCustomTool[], - setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void, + extensions: LoadedExtension[], + setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, initialMessage?: string, initialImages?: ImageContent[], fdPath: string | undefined = undefined, ): Promise { - const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath); + const mode = new InteractiveMode(session, version, changelogMarkdown, extensions, setExtensionUIContext, fdPath); await mode.init(); @@ -214,7 +212,7 @@ function buildSessionOptions( scopedModels: ScopedModel[], sessionManager: SessionManager | undefined, modelRegistry: ModelRegistry, - preloadedHooks?: import("./core/hooks/index.js").LoadedHook[], + preloadedExtensions?: LoadedExtension[], ): CreateAgentSessionOptions { const options: CreateAgentSessionOptions = {}; @@ -273,14 +271,9 @@ function buildSessionOptions( options.skills = []; } - // Pre-loaded hooks (from early CLI flag discovery) - if (preloadedHooks && preloadedHooks.length > 0) { - options.preloadedHooks = preloadedHooks; - } - - // Additional custom tool paths from CLI - if (parsed.customTools && parsed.customTools.length > 0) { - options.additionalCustomToolPaths = parsed.customTools; + // Pre-loaded extensions (from early CLI flag discovery) + if (preloadedExtensions && preloadedExtensions.length > 0) { + options.preloadedExtensions = preloadedExtensions; } return options; @@ -297,35 +290,35 @@ export async function main(args: string[]) { const modelRegistry = discoverModels(authStorage); time("discoverModels"); - // First pass: parse args to get --hook paths + // First pass: parse args to get --extension paths const firstPass = parseArgs(args); time("parseArgs-firstPass"); - // Early load hooks to discover their CLI flags + // Early load extensions to discover their CLI flags const cwd = process.cwd(); const agentDir = getAgentDir(); const eventBus = createEventBus(); - const hookPaths = firstPass.hooks ?? []; - const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir, eventBus); - time("discoverHookFlags"); + const extensionPaths = firstPass.extensions ?? []; + const { extensions: loadedExtensions } = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus); + time("discoverExtensionFlags"); - // Collect all hook flags - const hookFlags = new Map(); - for (const hook of loadedHooks) { - for (const [name, flag] of hook.flags) { - hookFlags.set(name, { type: flag.type }); + // Collect all extension flags + const extensionFlags = new Map(); + for (const ext of loadedExtensions) { + for (const [name, flag] of ext.flags) { + extensionFlags.set(name, { type: flag.type }); } } - // Second pass: parse args with hook flags - const parsed = parseArgs(args, hookFlags); + // Second pass: parse args with extension flags + const parsed = parseArgs(args, extensionFlags); time("parseArgs"); - // Pass flag values to hooks + // Pass flag values to extensions for (const [name, value] of parsed.unknownFlags) { - for (const hook of loadedHooks) { - if (hook.flags.has(name)) { - hook.setFlagValue(name, value); + for (const ext of loadedExtensions) { + if (ext.flags.has(name)) { + ext.setFlagValue(name, value); } } } @@ -401,7 +394,7 @@ export async function main(args: string[]) { sessionManager = SessionManager.open(selectedPath); } - const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks); + const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedExtensions); sessionOptions.authStorage = authStorage; sessionOptions.modelRegistry = modelRegistry; sessionOptions.eventBus = eventBus; @@ -416,7 +409,7 @@ export async function main(args: string[]) { } time("buildSessionOptions"); - const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); + const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); time("createAgentSession"); if (!isInteractive && !session.model) { @@ -469,8 +462,8 @@ export async function main(args: string[]) { migratedProviders, versionCheckPromise, parsed.messages, - customToolsResult.tools, - customToolsResult.setUIContext, + extensionsResult.extensions, + extensionsResult.setUIContext, initialMessage, initialImages, fdPath, diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index e83f32a4..497a62f7 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -2,7 +2,7 @@ import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozech import type { Theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -/** Loader wrapped with borders for hook UI */ +/** Loader wrapped with borders for extension UI */ export class BorderedLoader extends Container { private loader: CancellableLoader; diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index d46b2bc5..fedb2de1 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a branch summary message with collapsed/expanded state. - * Uses same background color as hook messages for visual consistency. + * Uses same background color as custom messages for visual consistency. */ export class BranchSummaryMessageComponent extends Box { private expanded = false; diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index dc07d3b5..fce96f08 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a compaction message with collapsed/expanded state. - * Uses same background color as hook messages for visual consistency. + * Uses same background color as custom messages for visual consistency. */ export class CompactionSummaryMessageComponent extends Box { private expanded = false; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index bb080a64..b9dc765b 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -12,8 +12,8 @@ export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlD?: () => void; public onPasteImage?: () => void; - /** Handler for hook-registered shortcuts. Returns true if handled. */ - public onHookShortcut?: (data: string) => boolean; + /** Handler for extension-registered shortcuts. Returns true if handled. */ + public onExtensionShortcut?: (data: string) => boolean; constructor(theme: EditorTheme, keybindings: KeybindingsManager) { super(theme); @@ -28,8 +28,8 @@ export class CustomEditor extends Editor { } handleInput(data: string): void { - // Check hook-registered shortcuts first - if (this.onHookShortcut?.(data)) { + // Check extension-registered shortcuts first + if (this.onExtensionShortcut?.(data)) { return; } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts similarity index 84% rename from packages/coding-agent/src/modes/interactive/components/hook-message.ts rename to packages/coding-agent/src/modes/interactive/components/custom-message.ts index 186e902f..3e535289 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -1,22 +1,22 @@ import type { TextContent } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { HookMessageRenderer } from "../../../core/hooks/types.js"; -import type { HookMessage } from "../../../core/messages.js"; +import type { MessageRenderer } from "../../../core/extensions/types.js"; +import type { CustomMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** - * Component that renders a custom message entry from hooks. + * Component that renders a custom message entry from extensions. * Uses distinct styling to differentiate from user messages. */ -export class HookMessageComponent extends Container { - private message: HookMessage; - private customRenderer?: HookMessageRenderer; +export class CustomMessageComponent extends Container { + private message: CustomMessage; + private customRenderer?: MessageRenderer; private box: Box; private customComponent?: Component; private _expanded = false; - constructor(message: HookMessage, customRenderer?: HookMessageRenderer) { + constructor(message: CustomMessage, customRenderer?: MessageRenderer) { super(); this.message = message; this.customRenderer = customRenderer; diff --git a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts index 2ee05ccb..51c08bc1 100644 --- a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -4,9 +4,9 @@ import { theme } from "../theme/theme.js"; /** * Dynamic border component that adjusts to viewport width. * - * Note: When used from hooks loaded via jiti, the global `theme` may be undefined + * Note: When used from extensions loaded via jiti, the global `theme` may be undefined * because jiti creates a separate module cache. Always pass an explicit color - * function when using DynamicBorder in components exported for hook use. + * function when using DynamicBorder in components exported for extension use. */ export class DynamicBorder implements Component { private color: (str: string) => string; diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts similarity index 93% rename from packages/coding-agent/src/modes/interactive/components/hook-editor.ts rename to packages/coding-agent/src/modes/interactive/components/extension-editor.ts index c07a634c..f8c3d0da 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -1,5 +1,5 @@ /** - * Multi-line editor component for hooks. + * Multi-line editor component for extensions. * Supports Ctrl+G for external editor. */ @@ -11,7 +11,7 @@ import { Container, Editor, getEditorKeybindings, matchesKey, Spacer, Text, type import { getEditorTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -export class HookEditorComponent extends Container { +export class ExtensionEditorComponent extends Container { private editor: Editor; private onSubmitCallback: (value: string) => void; private onCancelCallback: () => void; @@ -91,7 +91,7 @@ export class HookEditorComponent extends Container { } const currentText = this.editor.getText(); - const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`); + const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`); try { fs.writeFileSync(tmpFile, currentText, "utf-8"); diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/extension-input.ts similarity index 93% rename from packages/coding-agent/src/modes/interactive/components/hook-input.ts rename to packages/coding-agent/src/modes/interactive/components/extension-input.ts index 0f56fc49..8771baca 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-input.ts @@ -1,12 +1,12 @@ /** - * Simple text input component for hooks. + * Simple text input component for extensions. */ import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -export class HookInputComponent extends Container { +export class ExtensionInputComponent extends Container { private input: Input; private onSubmitCallback: (value: string) => void; private onCancelCallback: () => void; diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts similarity index 95% rename from packages/coding-agent/src/modes/interactive/components/hook-selector.ts rename to packages/coding-agent/src/modes/interactive/components/extension-selector.ts index 756d463a..96a10581 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -1,5 +1,5 @@ /** - * Generic selector component for hooks. + * Generic selector component for extensions. * Displays a list of string options with keyboard navigation. */ @@ -7,7 +7,7 @@ import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi- import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -export class HookSelectorComponent extends Container { +export class ExtensionSelectorComponent extends Container { private options: string[]; private selectedIndex = 0; private listContainer: Container; diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 78db717c..2063e28f 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -46,7 +46,7 @@ export class FooterComponent implements Component { private gitWatcher: FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; - private hookStatuses: Map = new Map(); + private extensionStatuses: Map = new Map(); constructor(session: AgentSession) { this.session = session; @@ -57,17 +57,17 @@ export class FooterComponent implements Component { } /** - * Set hook status text to display in the footer. + * Set extension status text to display in the footer. * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width. * ANSI escape codes for styling are preserved. * @param key - Unique key to identify this status * @param text - Status text, or undefined to clear */ - setHookStatus(key: string, text: string | undefined): void { + setExtensionStatus(key: string, text: string | undefined): void { if (text === undefined) { - this.hookStatuses.delete(key); + this.extensionStatuses.delete(key); } else { - this.hookStatuses.set(key, text); + this.extensionStatuses.set(key, text); } } @@ -309,9 +309,9 @@ export class FooterComponent implements Component { const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; - // Add hook statuses on a single line, sorted by key alphabetically - if (this.hookStatuses.size > 0) { - const sortedStatuses = Array.from(this.hookStatuses.entries()) + // Add extension statuses on a single line, sorted by key alphabetically + if (this.extensionStatuses.size > 0) { + const sortedStatuses = Array.from(this.extensionStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const statusLine = sortedStatuses.join(" "); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index dc6fe453..53c1392f 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -11,7 +11,7 @@ import { type TUI, } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; -import type { CustomTool } from "../../../core/custom-tools/types.js"; +import type { ToolDefinition } from "../../../core/extensions/types.js"; import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { convertToPng } from "../../../utils/image-convert.js"; @@ -58,7 +58,7 @@ export class ToolExecutionComponent extends Container { private expanded = false; private showImages: boolean; private isPartial = true; - private customTool?: CustomTool; + private toolDefinition?: ToolDefinition; private ui: TUI; private cwd: string; private result?: { @@ -76,7 +76,7 @@ export class ToolExecutionComponent extends Container { toolName: string, args: any, options: ToolExecutionOptions = {}, - customTool: CustomTool | undefined, + toolDefinition: ToolDefinition | undefined, ui: TUI, cwd: string = process.cwd(), ) { @@ -84,7 +84,7 @@ export class ToolExecutionComponent extends Container { this.toolName = toolName; this.args = args; this.showImages = options.showImages ?? true; - this.customTool = customTool; + this.toolDefinition = toolDefinition; this.ui = ui; this.cwd = cwd; @@ -94,7 +94,7 @@ export class ToolExecutionComponent extends Container { this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); - if (customTool || toolName === "bash") { + if (toolDefinition || toolName === "bash") { this.addChild(this.contentBox); } else { this.addChild(this.contentText); @@ -214,15 +214,15 @@ export class ToolExecutionComponent extends Container { : (text: string) => theme.bg("toolSuccessBg", text); // Check for custom tool rendering - if (this.customTool) { + if (this.toolDefinition) { // Custom tools use Box for flexible component rendering this.contentBox.setBgFn(bgFn); this.contentBox.clear(); // Render call component - if (this.customTool.renderCall) { + if (this.toolDefinition.renderCall) { try { - const callComponent = this.customTool.renderCall(this.args, theme); + const callComponent = this.toolDefinition.renderCall(this.args, theme); if (callComponent) { this.contentBox.addChild(callComponent); } @@ -236,9 +236,9 @@ export class ToolExecutionComponent extends Container { } // Render result component if we have a result - if (this.result && this.customTool.renderResult) { + if (this.result && this.toolDefinition.renderResult) { try { - const resultComponent = this.customTool.renderResult( + const resultComponent = this.toolDefinition.renderResult( { content: this.result.content as any, details: this.result.details }, { expanded: this.expanded, isPartial: this.isPartial }, theme, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1b331d43..7bae033f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -30,8 +30,12 @@ import { import { exec, spawn, spawnSync } from "child_process"; import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; -import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; -import type { HookContext, HookRunner, HookUIContext } from "../../core/hooks/index.js"; +import type { + ExtensionContext, + ExtensionRunner, + ExtensionUIContext, + LoadedExtension, +} from "../../core/extensions/index.js"; import { KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -47,12 +51,12 @@ import { BorderedLoader } from "./components/bordered-loader.js"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; import { CustomEditor } from "./components/custom-editor.js"; +import { CustomMessageComponent } from "./components/custom-message.js"; import { DynamicBorder } from "./components/dynamic-border.js"; +import { ExtensionEditorComponent } from "./components/extension-editor.js"; +import { ExtensionInputComponent } from "./components/extension-input.js"; +import { ExtensionSelectorComponent } from "./components/extension-selector.js"; import { FooterComponent } from "./components/footer.js"; -import { HookEditorComponent } from "./components/hook-editor.js"; -import { HookInputComponent } from "./components/hook-input.js"; -import { HookMessageComponent } from "./components/hook-message.js"; -import { HookSelectorComponent } from "./components/hook-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; @@ -136,18 +140,15 @@ export class InteractiveMode { private retryLoader: Loader | undefined = undefined; private retryEscapeHandler?: () => void; - // Hook UI state - private hookSelector: HookSelectorComponent | undefined = undefined; - private hookInput: HookInputComponent | undefined = undefined; - private hookEditor: HookEditorComponent | undefined = undefined; + // Extension UI state + private extensionSelector: ExtensionSelectorComponent | undefined = undefined; + private extensionInput: ExtensionInputComponent | undefined = undefined; + private extensionEditor: ExtensionEditorComponent | undefined = undefined; - // Hook widgets (components rendered above the editor) - private hookWidgets = new Map(); + // Extension widgets (components rendered above the editor) + private extensionWidgets = new Map(); private widgetContainer!: Container; - // Custom tools for custom rendering - private customTools: Map; - // Convenience accessors private get agent() { return this.session.agent; @@ -163,14 +164,13 @@ export class InteractiveMode { session: AgentSession, version: string, changelogMarkdown: string | undefined = undefined, - customTools: LoadedCustomTool[] = [], - private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {}, + _extensions: LoadedExtension[] = [], + private setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {}, fdPath: string | undefined = undefined, ) { this.session = session; this.version = version; this.changelogMarkdown = changelogMarkdown; - this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct])); this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); @@ -183,7 +183,7 @@ export class InteractiveMode { this.footer = new FooterComponent(session); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); - // Define slash commands for autocomplete + // Define commands for autocomplete const slashCommands: SlashCommand[] = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, @@ -205,21 +205,23 @@ export class InteractiveMode { // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); - // Convert file commands to SlashCommand format - const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({ + // Convert prompt templates to SlashCommand format for autocomplete + const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, description: cmd.description, })); - // Convert hook commands to SlashCommand format - const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ - name: cmd.name, - description: cmd.description ?? "(hook command)", - })); + // Convert extension commands to SlashCommand format + const extensionCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map( + (cmd) => ({ + name: cmd.name, + description: cmd.description ?? "(extension command)", + }), + ); // Setup autocomplete const autocompleteProvider = new CombinedAutocompleteProvider( - [...slashCommands, ...fileSlashCommands, ...hookCommands], + [...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath, ); @@ -348,8 +350,8 @@ export class InteractiveMode { const cwdBasename = path.basename(process.cwd()); this.ui.terminal.setTitle(`pi - ${cwdBasename}`); - // Initialize hooks with TUI-based UI context - await this.initHooksAndCustomTools(); + // Initialize extensions with TUI-based UI context + await this.initExtensions(); // Subscribe to agent events this.subscribeToAgent(); @@ -368,13 +370,13 @@ export class InteractiveMode { } // ========================================================================= - // Hook System + // Extension System // ========================================================================= /** - * Initialize the hook system with TUI-based UI context. + * Initialize the extension system with TUI-based UI context. */ - private async initHooksAndCustomTools(): Promise { + private async initExtensions(): Promise { // Show loaded project context files const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { @@ -403,36 +405,21 @@ export class InteractiveMode { } } - // Show loaded custom tools - if (this.customTools.size > 0) { - const toolList = Array.from(this.customTools.values()) - .map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`)) - .join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded custom tools:\n") + toolList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); + // Create and set extension UI context + const uiContext = this.createExtensionUIContext(); + this.setExtensionUIContext(uiContext, true); + + const extensionRunner = this.session.extensionRunner; + if (!extensionRunner) { + return; // No extensions loaded } - // Create and set hook & tool UI context - const uiContext = this.createHookUIContext(); - this.setToolUIContext(uiContext, true); - - // Notify custom tools of session start - await this.emitCustomToolSessionEvent({ - reason: "start", - previousSessionFile: undefined, - }); - - const hookRunner = this.session.hookRunner; - if (!hookRunner) { - return; // No hooks loaded - } - - hookRunner.initialize({ + extensionRunner.initialize({ getModel: () => this.session.model, sendMessageHandler: (message, options) => { const wasStreaming = this.session.isStreaming; this.session - .sendHookMessage(message, options) + .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) @@ -441,7 +428,7 @@ export class InteractiveMode { } }) .catch((err) => { - this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); }); }, appendEntryHandler: (customType, data) => { @@ -522,71 +509,47 @@ export class InteractiveMode { hasUI: true, }); - // Subscribe to hook errors - hookRunner.onError((error) => { - this.showHookError(error.hookPath, error.error, error.stack); + // Subscribe to extension errors + extensionRunner.onError((error) => { + this.showExtensionError(error.extensionPath, error.error, error.stack); }); - // Set up hook-registered shortcuts - this.setupHookShortcuts(hookRunner); + // Set up extension-registered shortcuts + this.setupExtensionShortcuts(extensionRunner); - // Show loaded hooks - const hookPaths = hookRunner.getHookPaths(); - if (hookPaths.length > 0) { - const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0)); + // Show loaded extensions + const extensionPaths = extensionRunner.getExtensionPaths(); + if (extensionPaths.length > 0) { + const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n"); + this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } // Emit session_start event - await hookRunner.emit({ + await extensionRunner.emit({ type: "session_start", }); } /** - * Emit session event to all custom tools. + * Get a registered tool definition by name (for custom rendering). */ - private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise { - for (const { tool } of this.customTools.values()) { - if (tool.onSession) { - try { - await tool.onSession(event, { - sessionManager: this.session.sessionManager, - modelRegistry: this.session.modelRegistry, - model: this.session.model, - isIdle: () => !this.session.isStreaming, - hasPendingMessages: () => this.session.pendingMessageCount > 0, - abort: () => { - this.session.abort(); - }, - }); - } catch (err) { - this.showToolError(tool.name, err instanceof Error ? err.message : String(err)); - } - } - } + private getRegisteredToolDefinition(toolName: string) { + const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? []; + const registeredTool = tools.find((t) => t.definition.name === toolName); + return registeredTool?.definition; } /** - * Show a tool error in the chat. + * Set up keyboard shortcuts registered by extensions. */ - private showToolError(toolName: string, error: string): void { - const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0); - this.chatContainer.addChild(errorText); - this.ui.requestRender(); - } - - /** - * Set up keyboard shortcuts registered by hooks. - */ - private setupHookShortcuts(hookRunner: HookRunner): void { - const shortcuts = hookRunner.getShortcuts(); + private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void { + const shortcuts = extensionRunner.getShortcuts(); if (shortcuts.size === 0) return; // Create a context for shortcut handlers - const createContext = (): HookContext => ({ - ui: this.createHookUIContext(), + const createContext = (): ExtensionContext => ({ + ui: this.createExtensionUIContext(), hasUI: true, cwd: process.cwd(), sessionManager: this.sessionManager, @@ -597,10 +560,10 @@ export class InteractiveMode { hasPendingMessages: () => this.session.pendingMessageCount > 0, }); - // Set up the hook shortcut handler on the editor - this.editor.onHookShortcut = (data: string) => { + // Set up the extension shortcut handler on the editor + this.editor.onExtensionShortcut = (data: string) => { for (const [shortcutStr, shortcut] of shortcuts) { - // Cast to KeyId - hook shortcuts use the same format + // Cast to KeyId - extension shortcuts use the same format if (matchesKey(data, shortcutStr as KeyId)) { // Run handler async, don't block input Promise.resolve(shortcut.handler(createContext())).catch((err) => { @@ -614,26 +577,26 @@ export class InteractiveMode { } /** - * Set hook status text in the footer. + * Set extension status text in the footer. */ - private setHookStatus(key: string, text: string | undefined): void { - this.footer.setHookStatus(key, text); + private setExtensionStatus(key: string, text: string | undefined): void { + this.footer.setExtensionStatus(key, text); this.ui.requestRender(); } /** - * Set a hook widget (string array or custom component). + * Set an extension widget (string array or custom component). */ - private setHookWidget( + private setExtensionWidget( key: string, content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, ): void { // Dispose and remove existing widget - const existing = this.hookWidgets.get(key); + const existing = this.extensionWidgets.get(key); if (existing?.dispose) existing.dispose(); if (content === undefined) { - this.hookWidgets.delete(key); + this.extensionWidgets.delete(key); } else if (Array.isArray(content)) { // Wrap string array in a Container with Text components const container = new Container(); @@ -643,11 +606,11 @@ export class InteractiveMode { if (content.length > InteractiveMode.MAX_WIDGET_LINES) { container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); } - this.hookWidgets.set(key, container); + this.extensionWidgets.set(key, container); } else { // Factory function - create component const component = content(this.ui, theme); - this.hookWidgets.set(key, component); + this.extensionWidgets.set(key, component); } this.renderWidgets(); } @@ -656,18 +619,18 @@ export class InteractiveMode { private static readonly MAX_WIDGET_LINES = 10; /** - * Render all hook widgets to the widget container. + * Render all extension widgets to the widget container. */ private renderWidgets(): void { if (!this.widgetContainer) return; this.widgetContainer.clear(); - if (this.hookWidgets.size === 0) { + if (this.extensionWidgets.size === 0) { this.ui.requestRender(); return; } - for (const [_key, component] of this.hookWidgets) { + for (const [_key, component] of this.extensionWidgets) { this.widgetContainer.addChild(component); } @@ -675,21 +638,21 @@ export class InteractiveMode { } /** - * Create the HookUIContext for hooks and tools. + * Create the ExtensionUIContext for extensions. */ - private createHookUIContext(): HookUIContext { + private createExtensionUIContext(): ExtensionUIContext { return { - select: (title, options) => this.showHookSelector(title, options), - confirm: (title, message) => this.showHookConfirm(title, message), - input: (title, placeholder) => this.showHookInput(title, placeholder), - notify: (message, type) => this.showHookNotify(message, type), - setStatus: (key, text) => this.setHookStatus(key, text), - setWidget: (key, content) => this.setHookWidget(key, content), + select: (title, options) => this.showExtensionSelector(title, options), + confirm: (title, message) => this.showExtensionConfirm(title, message), + input: (title, placeholder) => this.showExtensionInput(title, placeholder), + notify: (message, type) => this.showExtensionNotify(message, type), + setStatus: (key, text) => this.setExtensionStatus(key, text), + setWidget: (key, content) => this.setExtensionWidget(key, content), setTitle: (title) => this.ui.terminal.setTitle(title), - custom: (factory) => this.showHookCustom(factory), + custom: (factory) => this.showExtensionCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), - editor: (title, prefill) => this.showHookEditor(title, prefill), + editor: (title, prefill) => this.showExtensionEditor(title, prefill), get theme() { return theme; }, @@ -697,126 +660,126 @@ export class InteractiveMode { } /** - * Show a selector for hooks. + * Show a selector for extensions. */ - private showHookSelector(title: string, options: string[]): Promise { + private showExtensionSelector(title: string, options: string[]): Promise { return new Promise((resolve) => { - this.hookSelector = new HookSelectorComponent( + this.extensionSelector = new ExtensionSelectorComponent( title, options, (option) => { - this.hideHookSelector(); + this.hideExtensionSelector(); resolve(option); }, () => { - this.hideHookSelector(); + this.hideExtensionSelector(); resolve(undefined); }, ); this.editorContainer.clear(); - this.editorContainer.addChild(this.hookSelector); - this.ui.setFocus(this.hookSelector); + this.editorContainer.addChild(this.extensionSelector); + this.ui.setFocus(this.extensionSelector); this.ui.requestRender(); }); } /** - * Hide the hook selector. + * Hide the extension selector. */ - private hideHookSelector(): void { + private hideExtensionSelector(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); - this.hookSelector = undefined; + this.extensionSelector = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** - * Show a confirmation dialog for hooks. + * Show a confirmation dialog for extensions. */ - private async showHookConfirm(title: string, message: string): Promise { - const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]); + private async showExtensionConfirm(title: string, message: string): Promise { + const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]); return result === "Yes"; } /** - * Show a text input for hooks. + * Show a text input for extensions. */ - private showHookInput(title: string, placeholder?: string): Promise { + private showExtensionInput(title: string, placeholder?: string): Promise { return new Promise((resolve) => { - this.hookInput = new HookInputComponent( + this.extensionInput = new ExtensionInputComponent( title, placeholder, (value) => { - this.hideHookInput(); + this.hideExtensionInput(); resolve(value); }, () => { - this.hideHookInput(); + this.hideExtensionInput(); resolve(undefined); }, ); this.editorContainer.clear(); - this.editorContainer.addChild(this.hookInput); - this.ui.setFocus(this.hookInput); + this.editorContainer.addChild(this.extensionInput); + this.ui.setFocus(this.extensionInput); this.ui.requestRender(); }); } /** - * Hide the hook input. + * Hide the extension input. */ - private hideHookInput(): void { + private hideExtensionInput(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); - this.hookInput = undefined; + this.extensionInput = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** - * Show a multi-line editor for hooks (with Ctrl+G support). + * Show a multi-line editor for extensions (with Ctrl+G support). */ - private showHookEditor(title: string, prefill?: string): Promise { + private showExtensionEditor(title: string, prefill?: string): Promise { return new Promise((resolve) => { - this.hookEditor = new HookEditorComponent( + this.extensionEditor = new ExtensionEditorComponent( this.ui, title, prefill, (value) => { - this.hideHookEditor(); + this.hideExtensionEditor(); resolve(value); }, () => { - this.hideHookEditor(); + this.hideExtensionEditor(); resolve(undefined); }, ); this.editorContainer.clear(); - this.editorContainer.addChild(this.hookEditor); - this.ui.setFocus(this.hookEditor); + this.editorContainer.addChild(this.extensionEditor); + this.ui.setFocus(this.extensionEditor); this.ui.requestRender(); }); } /** - * Hide the hook editor. + * Hide the extension editor. */ - private hideHookEditor(): void { + private hideExtensionEditor(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); - this.hookEditor = undefined; + this.extensionEditor = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** - * Show a notification for hooks. + * Show a notification for extensions. */ - private showHookNotify(message: string, type?: "info" | "warning" | "error"): void { + private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void { if (type === "error") { this.showError(message); } else if (type === "warning") { @@ -829,7 +792,7 @@ export class InteractiveMode { /** * Show a custom component with keyboard focus. */ - private async showHookCustom( + private async showExtensionCustom( factory: ( tui: TUI, theme: Theme, @@ -862,10 +825,10 @@ export class InteractiveMode { } /** - * Show a hook error in the UI. + * Show an extension error in the UI. */ - private showHookError(hookPath: string, error: string, stack?: string): void { - const errorMsg = `Hook "${hookPath}" error: ${error}`; + private showExtensionError(extensionPath: string, error: string, stack?: string): void { + const errorMsg = `Extension "${extensionPath}" error: ${error}`; const errorText = new Text(theme.fg("error", errorMsg), 1, 0); this.chatContainer.addChild(errorText); if (stack) { @@ -882,10 +845,6 @@ export class InteractiveMode { this.ui.requestRender(); } - /** - * Handle pi.send() from hooks. - * If streaming, queue the message. Otherwise, start a new agent loop. - */ // ========================================================================= // Key Handlers // ========================================================================= @@ -984,7 +943,7 @@ export class InteractiveMode { text = text.trim(); if (!text) return; - // Handle slash commands + // Handle commands if (text === "/settings") { this.showSettingsSelector(); this.editor.setText(""); @@ -1106,7 +1065,7 @@ export class InteractiveMode { } // If streaming, use prompt() with steer behavior - // This handles hook commands (execute immediately), slash command expansion, and queueing + // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory(text); this.editor.setText(""); @@ -1157,7 +1116,7 @@ export class InteractiveMode { break; case "message_start": - if (event.message.role === "hookMessage") { + if (event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -1189,7 +1148,7 @@ export class InteractiveMode { { showImages: this.settingsManager.getShowImages(), }, - this.customTools.get(content.name)?.tool, + this.getRegisteredToolDefinition(content.name), this.ui, ); component.setExpanded(this.toolOutputExpanded); @@ -1246,7 +1205,7 @@ export class InteractiveMode { { showImages: this.settingsManager.getShowImages(), }, - this.customTools.get(event.toolName)?.tool, + this.getRegisteredToolDefinition(event.toolName), this.ui, ); component.setExpanded(this.toolOutputExpanded); @@ -1441,10 +1400,10 @@ export class InteractiveMode { this.chatContainer.addChild(component); break; } - case "hookMessage": { + case "custom": { if (message.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new HookMessageComponent(message, renderer)); + const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType); + this.chatContainer.addChild(new CustomMessageComponent(message, renderer)); } break; } @@ -1516,7 +1475,7 @@ export class InteractiveMode { content.name, content.arguments, { showImages: this.settingsManager.getShowImages() }, - this.customTools.get(content.name)?.tool, + this.getRegisteredToolDefinition(content.name), this.ui, ); component.setExpanded(this.toolOutputExpanded); @@ -1601,20 +1560,17 @@ export class InteractiveMode { /** * Gracefully shutdown the agent. - * Emits shutdown event to hooks and tools, then exits. + * Emits shutdown event to extensions, then exits. */ private async shutdown(): Promise { - // Emit shutdown event to hooks - const hookRunner = this.session.hookRunner; - if (hookRunner?.hasHandlers("session_shutdown")) { - await hookRunner.emit({ + // Emit shutdown event to extensions + const extensionRunner = this.session.extensionRunner; + if (extensionRunner?.hasHandlers("session_shutdown")) { + await extensionRunner.emit({ type: "session_shutdown", }); } - // Emit shutdown event to custom tools - await this.session.emitCustomToolSessionEvent("shutdown"); - this.stop(); process.exit(0); } @@ -1638,7 +1594,7 @@ export class InteractiveMode { if (!text) return; // Alt+Enter queues a follow-up message (waits until agent finishes) - // This handles hook commands (execute immediately), slash command expansion, and queueing + // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory(text); this.editor.setText(""); @@ -1979,7 +1935,7 @@ export class InteractiveMode { async (entryId) => { const result = await this.session.branch(entryId); if (result.cancelled) { - // Hook cancelled the branch + // Extension cancelled the branch done(); this.ui.requestRender(); return; @@ -2034,7 +1990,7 @@ export class InteractiveMode { // Ask about summarization done(); // Close selector first - const wantsSummary = await this.showHookConfirm( + const wantsSummary = await this.showExtensionConfirm( "Summarize branch?", "Create a summary of the branch you're leaving?", ); @@ -2137,7 +2093,7 @@ export class InteractiveMode { this.streamingMessage = undefined; this.pendingTools.clear(); - // Switch session via AgentSession (emits hook and tool session events) + // Switch session via AgentSession (emits extension session events) await this.session.switchSession(sessionPath); // Clear and re-render the chat @@ -2542,18 +2498,18 @@ export class InteractiveMode { | \`!\` | Run bash command | `; - // Add hook-registered shortcuts - const hookRunner = this.session.hookRunner; - if (hookRunner) { - const shortcuts = hookRunner.getShortcuts(); + // Add extension-registered shortcuts + const extensionRunner = this.session.extensionRunner; + if (extensionRunner) { + const shortcuts = extensionRunner.getShortcuts(); if (shortcuts.size > 0) { hotkeys += ` -**Hooks** +**Extensions** | Key | Action | |-----|--------| `; for (const [key, shortcut] of shortcuts) { - const description = shortcut.description ?? shortcut.hookPath; + const description = shortcut.description ?? shortcut.extensionPath; hotkeys += `| \`${key}\` | ${description} |\n`; } } @@ -2576,7 +2532,7 @@ export class InteractiveMode { } this.statusContainer.clear(); - // New session via session (emits hook and tool session events) + // New session via session (emits extension session events) await this.session.newSession(); // Clear UI state diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 5beb31cd..38857288 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -26,15 +26,15 @@ export async function runPrintMode( initialMessage?: string, initialImages?: ImageContent[], ): Promise { - // Hook runner already has no-op UI context by default (set in main.ts) - // Set up hooks for print mode (no UI) - const hookRunner = session.hookRunner; - if (hookRunner) { - hookRunner.initialize({ + // Extension runner already has no-op UI context by default (set in loader) + // Set up extensions for print mode (no UI) + const extensionRunner = session.extensionRunner; + if (extensionRunner) { + extensionRunner.initialize({ getModel: () => session.model, sendMessageHandler: (message, options) => { - session.sendHookMessage(message, options).catch((e) => { - console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + session.sendCustomMessage(message, options).catch((e) => { + console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); }); }, appendEntryHandler: (customType, data) => { @@ -44,41 +44,15 @@ export async function runPrintMode( getAllToolsHandler: () => session.getAllToolNames(), setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), }); - hookRunner.onError((err) => { - console.error(`Hook error (${err.hookPath}): ${err.error}`); + extensionRunner.onError((err) => { + console.error(`Extension error (${err.extensionPath}): ${err.error}`); }); // Emit session_start event - await hookRunner.emit({ + await extensionRunner.emit({ type: "session_start", }); } - // Emit session start event to custom tools (no UI in print mode) - for (const { tool } of session.customTools) { - if (tool.onSession) { - try { - await tool.onSession( - { - reason: "start", - previousSessionFile: undefined, - }, - { - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, - model: session.model, - isIdle: () => !session.isStreaming, - hasPendingMessages: () => session.pendingMessageCount > 0, - abort: () => { - session.abort(); - }, - }, - ); - } catch (_err) { - // Silently ignore tool errors - } - } - } - // Always subscribe to enable session persistence via _handleAgentEvent session.subscribe((event) => { // In JSON mode, output all events diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 39b89156..6269a96c 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -196,7 +196,7 @@ export class RpcClient { /** * Start a new session, optionally with parent tracking. * @param parentSession - Optional parent session path for lineage tracking - * @returns Object with `cancelled: true` if a hook cancelled the new session + * @returns Object with `cancelled: true` if an extension cancelled the new session */ async newSession(parentSession?: string): Promise<{ cancelled: boolean }> { const response = await this.send({ type: "new_session", parentSession }); @@ -330,7 +330,7 @@ export class RpcClient { /** * Switch to a different session file. - * @returns Object with `cancelled: true` if a hook cancelled the switch + * @returns Object with `cancelled: true` if an extension cancelled the switch */ async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { const response = await this.send({ type: "switch_session", sessionPath }); @@ -339,7 +339,7 @@ export class RpcClient { /** * Branch from a specific message. - * @returns Object with `text` (the message text) and `cancelled` (if hook cancelled) + * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled) */ async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { const response = await this.send({ type: "branch", entryId }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 8f158cee..a4cfe9bc 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -8,25 +8,37 @@ * - Commands: JSON objects with `type` field, optional `id` for correlation * - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error` * - Events: AgentSessionEvent objects streamed as they occur - * - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response + * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ import * as crypto from "node:crypto"; import * as readline from "readline"; import type { AgentSession } from "../../core/agent-session.js"; -import type { HookUIContext } from "../../core/hooks/index.js"; +import type { ExtensionUIContext } from "../../core/extensions/index.js"; import { theme } from "../interactive/theme/theme.js"; -import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js"; +import type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc-types.js"; // Re-export types for consumers -export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js"; +export type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc-types.js"; /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(session: AgentSession): Promise { - const output = (obj: RpcResponse | RpcHookUIRequest | object) => { + const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { console.log(JSON.stringify(obj)); }; @@ -45,18 +57,21 @@ export async function runRpcMode(session: AgentSession): Promise { return { id, type: "response", command, success: false, error: message }; }; - // Pending hook UI requests waiting for response - const pendingHookRequests = new Map void; reject: (error: Error) => void }>(); + // Pending extension UI requests waiting for response + const pendingExtensionRequests = new Map< + string, + { resolve: (value: any) => void; reject: (error: Error) => void } + >(); /** - * Create a hook UI context that uses the RPC protocol. + * Create an extension UI context that uses the RPC protocol. */ - const createHookUIContext = (): HookUIContext => ({ + const createExtensionUIContext = (): ExtensionUIContext => ({ async select(title: string, options: string[]): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -67,15 +82,15 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest); }); }, async confirm(title: string, message: string): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(false); } else if ("confirmed" in response) { @@ -86,15 +101,15 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest); }); }, async input(title: string, placeholder?: string): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -105,42 +120,42 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest); }); }, notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed output({ - type: "hook_ui_request", + type: "extension_ui_request", id: crypto.randomUUID(), method: "notify", message, notifyType: type, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, setStatus(key: string, text: string | undefined): void { // Fire and forget - no response needed output({ - type: "hook_ui_request", + type: "extension_ui_request", id: crypto.randomUUID(), method: "setStatus", statusKey: key, statusText: text, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, setWidget(key: string, content: unknown): void { // Only support string arrays in RPC mode - factory functions are ignored if (content === undefined || Array.isArray(content)) { output({ - type: "hook_ui_request", + type: "extension_ui_request", id: crypto.randomUUID(), method: "setWidget", widgetKey: key, widgetLines: content as string[] | undefined, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); } // Component factories are not supported in RPC mode - would need TUI access }, @@ -148,11 +163,11 @@ export async function runRpcMode(session: AgentSession): Promise { setTitle(title: string): void { // Fire and forget - host can implement terminal title control output({ - type: "hook_ui_request", + type: "extension_ui_request", id: crypto.randomUUID(), method: "setTitle", title, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, async custom() { @@ -163,11 +178,11 @@ export async function runRpcMode(session: AgentSession): Promise { setEditorText(text: string): void { // Fire and forget - host can implement editor control output({ - type: "hook_ui_request", + type: "extension_ui_request", id: crypto.randomUUID(), method: "set_editor_text", text, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, getEditorText(): string { @@ -179,8 +194,8 @@ export async function runRpcMode(session: AgentSession): Promise { async editor(title: string, prefill?: string): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -191,7 +206,7 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest); }); }, @@ -200,14 +215,14 @@ export async function runRpcMode(session: AgentSession): Promise { }, }); - // Set up hooks with RPC-based UI context - const hookRunner = session.hookRunner; - if (hookRunner) { - hookRunner.initialize({ + // Set up extensions with RPC-based UI context + const extensionRunner = session.extensionRunner; + if (extensionRunner) { + extensionRunner.initialize({ getModel: () => session.agent.state.model, sendMessageHandler: (message, options) => { - session.sendHookMessage(message, options).catch((e) => { - output(error(undefined, "hook_send", e.message)); + session.sendCustomMessage(message, options).catch((e) => { + output(error(undefined, "extension_send", e.message)); }); }, appendEntryHandler: (customType, data) => { @@ -216,45 +231,18 @@ export async function runRpcMode(session: AgentSession): Promise { getActiveToolsHandler: () => session.getActiveToolNames(), getAllToolsHandler: () => session.getAllToolNames(), setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - uiContext: createHookUIContext(), + uiContext: createExtensionUIContext(), hasUI: false, }); - hookRunner.onError((err) => { - output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); + extensionRunner.onError((err) => { + output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }); // Emit session_start event - await hookRunner.emit({ + await extensionRunner.emit({ type: "session_start", }); } - // Emit session start event to custom tools - // Note: Tools get no-op UI context in RPC mode (host handles UI via protocol) - for (const { tool } of session.customTools) { - if (tool.onSession) { - try { - await tool.onSession( - { - previousSessionFile: undefined, - reason: "start", - }, - { - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, - model: session.model, - isIdle: () => !session.isStreaming, - hasPendingMessages: () => session.pendingMessageCount > 0, - abort: () => { - session.abort(); - }, - }, - ); - } catch (_err) { - // Silently ignore tool errors - } - } - } - // Output all agent events as JSON session.subscribe((event) => { output(event); @@ -271,7 +259,7 @@ export async function runRpcMode(session: AgentSession): Promise { case "prompt": { // Don't await - events will stream - // Hook commands are executed immediately, file slash commands are expanded + // Extension commands are executed immediately, file prompt templates are expanded // If streaming and streamingBehavior specified, queues via steer/followUp session .prompt(command.message, { @@ -484,12 +472,12 @@ export async function runRpcMode(session: AgentSession): Promise { try { const parsed = JSON.parse(line); - // Handle hook UI responses - if (parsed.type === "hook_ui_response") { - const response = parsed as RpcHookUIResponse; - const pending = pendingHookRequests.get(response.id); + // Handle extension UI responses + if (parsed.type === "extension_ui_response") { + const response = parsed as RpcExtensionUIResponse; + const pending = pendingExtensionRequests.get(response.id); if (pending) { - pendingHookRequests.delete(response.id); + pendingExtensionRequests.delete(response.id); pending.resolve(response); } return; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 8866b6c9..0ae638ba 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -172,42 +172,48 @@ export type RpcResponse = | { id?: string; type: "response"; command: string; success: false; error: string }; // ============================================================================ -// Hook UI Events (stdout) +// Extension UI Events (stdout) // ============================================================================ -/** Emitted when a hook needs user input */ -export type RpcHookUIRequest = - | { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] } - | { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string } - | { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string } - | { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string } +/** Emitted when an extension needs user input */ +export type RpcExtensionUIRequest = + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { - type: "hook_ui_request"; + type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error"; } - | { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } | { - type: "hook_ui_request"; + type: "extension_ui_request"; + id: string; + method: "setStatus"; + statusKey: string; + statusText: string | undefined; + } + | { + type: "extension_ui_request"; id: string; method: "setWidget"; widgetKey: string; widgetLines: string[] | undefined; } - | { type: "hook_ui_request"; id: string; method: "setTitle"; title: string } - | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ -// Hook UI Commands (stdin) +// Extension UI Commands (stdin) // ============================================================================ -/** Response to a hook UI request */ -export type RpcHookUIResponse = - | { type: "hook_ui_response"; id: string; value: string } - | { type: "hook_ui_response"; id: string; confirmed: boolean } - | { type: "hook_ui_response"; id: string; cancelled: true }; +/** Response to an extension UI request */ +export type RpcExtensionUIResponse = + | { type: "extension_ui_response"; id: string; value: string } + | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; cancelled: true }; // ============================================================================ // Helper type for extracting command types diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index ab764ee1..9f809a51 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -133,15 +133,20 @@ describe("parseArgs", () => { }); }); - describe("--hook flag", () => { - test("parses single --hook", () => { - const result = parseArgs(["--hook", "./my-hook.ts"]); - expect(result.hooks).toEqual(["./my-hook.ts"]); + describe("--extension flag", () => { + test("parses single --extension", () => { + const result = parseArgs(["--extension", "./my-extension.ts"]); + expect(result.extensions).toEqual(["./my-extension.ts"]); }); - test("parses multiple --hook flags", () => { - const result = parseArgs(["--hook", "./hook1.ts", "--hook", "./hook2.ts"]); - expect(result.hooks).toEqual(["./hook1.ts", "./hook2.ts"]); + test("parses -e shorthand", () => { + const result = parseArgs(["-e", "./my-extension.ts"]); + expect(result.extensions).toEqual(["./my-extension.ts"]); + }); + + test("parses multiple --extension flags", () => { + const result = parseArgs(["--extension", "./ext1.ts", "-e", "./ext2.ts"]); + expect(result.extensions).toEqual(["./ext1.ts", "./ext2.ts"]); }); }); diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-extensions-example.test.ts similarity index 76% rename from packages/coding-agent/test/compaction-hooks-example.test.ts rename to packages/coding-agent/test/compaction-extensions-example.test.ts index 64e6cec6..88159994 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-extensions-example.test.ts @@ -1,14 +1,14 @@ /** - * Verify the documentation example from hooks.md compiles and works. + * Verify the documentation example from extensions.md compiles and works. */ import { describe, expect, it } from "vitest"; -import type { HookAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/hooks/index.js"; +import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.js"; describe("Documentation example", () => { it("custom compaction example should type-check correctly", () => { - // This is the example from hooks.md - verify it compiles - const exampleHook = (pi: HookAPI) => { + // This is the example from extensions.md - verify it compiles + const exampleExtension = (pi: ExtensionAPI) => { pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => { // All these should be accessible on the event const { preparation, branchEntries } = event; @@ -32,7 +32,7 @@ describe("Documentation example", () => { .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) .join("\n"); - // Hooks return compaction content - SessionManager adds id/parentId + // Extensions return compaction content - SessionManager adds id/parentId return { compaction: { summary: `User requests:\n${summary}`, @@ -44,20 +44,20 @@ describe("Documentation example", () => { }; // Just verify the function exists and is callable - expect(typeof exampleHook).toBe("function"); + expect(typeof exampleExtension).toBe("function"); }); it("compact event should have correct fields", () => { - const checkCompactEvent = (pi: HookAPI) => { + const checkCompactEvent = (pi: ExtensionAPI) => { pi.on("session_compact", async (event: SessionCompactEvent) => { // These should all be accessible const entry = event.compactionEntry; - const fromHook = event.fromHook; + const fromExtension = event.fromExtension; expect(entry.type).toBe("compaction"); expect(typeof entry.summary).toBe("string"); expect(typeof entry.tokensBefore).toBe("number"); - expect(typeof fromHook).toBe("boolean"); + expect(typeof fromExtension).toBe("boolean"); }); }; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts similarity index 81% rename from packages/coding-agent/test/compaction-hooks.test.ts rename to packages/coding-agent/test/compaction-extensions.test.ts index 03e6ac26..d6158fd6 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -1,5 +1,5 @@ /** - * Tests for compaction hook events (before_compact / compact). + * Tests for compaction extension events (before_compact / compact). */ import { existsSync, mkdirSync, rmSync } from "node:fs"; @@ -11,12 +11,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; import { - HookRunner, - type LoadedHook, + ExtensionRunner, + type LoadedExtension, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionEvent, -} from "../src/core/hooks/index.js"; +} from "../src/core/extensions/index.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; @@ -25,14 +25,14 @@ import { theme } from "../src/modes/interactive/theme/theme.js"; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; -describe.skipIf(!API_KEY)("Compaction hooks", () => { +describe.skipIf(!API_KEY)("Compaction extensions", () => { let session: AgentSession; let tempDir: string; - let hookRunner: HookRunner; + let extensionRunner: ExtensionRunner; let capturedEvents: SessionEvent[]; beforeEach(() => { - tempDir = join(tmpdir(), `pi-compaction-hooks-test-${Date.now()}`); + tempDir = join(tmpdir(), `pi-compaction-extensions-test-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); capturedEvents = []; }); @@ -46,10 +46,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } }); - function createHook( + function createExtension( onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined, onCompact?: (event: SessionCompactEvent) => void, - ): LoadedHook { + ): LoadedExtension { const handlers = new Map Promise)[]>(); handlers.set("session_before_compact", [ @@ -73,9 +73,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ]); return { - path: "test-hook", - resolvedPath: "/test/test-hook.ts", + path: "test-extension", + resolvedPath: "/test/test-extension.ts", handlers, + tools: new Map(), messageRenderers: new Map(), commands: new Map(), flags: new Map(), @@ -90,7 +91,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }; } - function createSession(hooks: LoadedHook[]) { + function createSession(extensions: LoadedExtension[]) { const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => API_KEY, @@ -106,8 +107,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const authStorage = new AuthStorage(join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); - hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); - hookRunner.initialize({ + extensionRunner = new ExtensionRunner(extensions, tempDir, sessionManager, modelRegistry); + extensionRunner.initialize({ getModel: () => session.model, sendMessageHandler: async () => {}, appendEntryHandler: async () => {}, @@ -137,7 +138,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { agent, sessionManager, settingsManager, - hookRunner, + extensionRunner, modelRegistry, }); @@ -145,8 +146,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } it("should emit before_compact and compact events", async () => { - const hook = createHook(); - createSession([hook]); + const extension = createExtension(); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -177,12 +178,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(afterEvent.compactionEntry).toBeDefined(); expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); - expect(afterEvent.fromHook).toBe(false); + expect(afterEvent.fromExtension).toBe(false); }, 120000); - it("should allow hooks to cancel compaction", async () => { - const hook = createHook(() => ({ cancel: true })); - createSession([hook]); + it("should allow extensions to cancel compaction", async () => { + const extension = createExtension(() => ({ cancel: true })); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -193,10 +194,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(compactEvents.length).toBe(0); }, 120000); - it("should allow hooks to provide custom compaction", async () => { - const customSummary = "Custom summary from hook"; + it("should allow extensions to provide custom compaction", async () => { + const customSummary = "Custom summary from extension"; - const hook = createHook((event) => { + const extension = createExtension((event) => { if (event.type === "session_before_compact") { return { compaction: { @@ -208,7 +209,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } return undefined; }); - createSession([hook]); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -226,13 +227,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const afterEvent = compactEvents[0]; if (afterEvent.type === "session_compact") { expect(afterEvent.compactionEntry.summary).toBe(customSummary); - expect(afterEvent.fromHook).toBe(true); + expect(afterEvent.fromExtension).toBe(true); } }, 120000); it("should include entries in compact event after compaction is saved", async () => { - const hook = createHook(); - createSession([hook]); + const extension = createExtension(); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -251,17 +252,17 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } }, 120000); - it("should continue with default compaction if hook throws error", async () => { - const throwingHook: LoadedHook = { - path: "throwing-hook", - resolvedPath: "/test/throwing-hook.ts", + it("should continue with default compaction if extension throws error", async () => { + const throwingExtension: LoadedExtension = { + path: "throwing-extension", + resolvedPath: "/test/throwing-extension.ts", handlers: new Map Promise)[]>([ [ "session_before_compact", [ async (event: SessionBeforeCompactEvent) => { capturedEvents.push(event); - throw new Error("Hook intentionally throws"); + throw new Error("Extension intentionally throws"); }, ], ], @@ -275,6 +276,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), + tools: new Map(), messageRenderers: new Map(), commands: new Map(), flags: new Map(), @@ -288,7 +290,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { setFlagValue: () => {}, }; - createSession([throwingHook]); + createSession([throwingExtension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -300,21 +302,21 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact"); expect(compactEvents.length).toBe(1); - expect(compactEvents[0].fromHook).toBe(false); + expect(compactEvents[0].fromExtension).toBe(false); }, 120000); - it("should call multiple hooks in order", async () => { + it("should call multiple extensions in order", async () => { const callOrder: string[] = []; - const hook1: LoadedHook = { - path: "hook1", - resolvedPath: "/test/hook1.ts", + const extension1: LoadedExtension = { + path: "extension1", + resolvedPath: "/test/extension1.ts", handlers: new Map Promise)[]>([ [ "session_before_compact", [ async () => { - callOrder.push("hook1-before"); + callOrder.push("extension1-before"); return undefined; }, ], @@ -323,12 +325,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { "session_compact", [ async () => { - callOrder.push("hook1-after"); + callOrder.push("extension1-after"); return undefined; }, ], ], ]), + tools: new Map(), messageRenderers: new Map(), commands: new Map(), flags: new Map(), @@ -342,15 +345,15 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { setFlagValue: () => {}, }; - const hook2: LoadedHook = { - path: "hook2", - resolvedPath: "/test/hook2.ts", + const extension2: LoadedExtension = { + path: "extension2", + resolvedPath: "/test/extension2.ts", handlers: new Map Promise)[]>([ [ "session_before_compact", [ async () => { - callOrder.push("hook2-before"); + callOrder.push("extension2-before"); return undefined; }, ], @@ -359,12 +362,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { "session_compact", [ async () => { - callOrder.push("hook2-after"); + callOrder.push("extension2-after"); return undefined; }, ], ], ]), + tools: new Map(), messageRenderers: new Map(), commands: new Map(), flags: new Map(), @@ -378,24 +382,24 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { setFlagValue: () => {}, }; - createSession([hook1, hook2]); + createSession([extension1, extension2]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); await session.compact(); - expect(callOrder).toEqual(["hook1-before", "hook2-before", "hook1-after", "hook2-after"]); + expect(callOrder).toEqual(["extension1-before", "extension2-before", "extension1-after", "extension2-after"]); }, 120000); it("should pass correct data in before_compact event", async () => { let capturedBeforeEvent: SessionBeforeCompactEvent | null = null; - const hook = createHook((event) => { + const extension = createExtension((event) => { capturedBeforeEvent = event; return undefined; }); - createSession([hook]); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -427,10 +431,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(entries.length).toBeGreaterThan(0); }, 120000); - it("should use hook compaction even with different values", async () => { + it("should use extension compaction even with different values", async () => { const customSummary = "Custom summary with modified values"; - const hook = createHook((event) => { + const extension = createExtension((event) => { if (event.type === "session_before_compact") { return { compaction: { @@ -442,7 +446,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } return undefined; }); - createSession([hook]); + createSession([extension]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 55336bec..fa2edf88 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -1,9 +1,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + describe("extensions discovery", () => { let tempDir: string; let extensionsDir: string; @@ -293,4 +296,151 @@ describe("extensions discovery", () => { expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("my-ext.ts"); }); + + it("resolves 3rd party npm dependencies (chalk)", async () => { + // Load the real chalk-logger extension from examples + const chalkLoggerPath = path.resolve(__dirname, "../examples/extensions/chalk-logger.ts"); + + const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("chalk-logger.ts"); + // The extension registers event handlers, not commands/tools + expect(result.extensions[0].handlers.size).toBeGreaterThan(0); + }); + + it("resolves dependencies from extension's own node_modules", async () => { + // Load extension that has its own package.json and node_modules with 'ms' package + const extPath = path.resolve(__dirname, "../examples/extensions/with-deps"); + + const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("with-deps"); + // The extension registers a 'parse_duration' tool + expect(result.extensions[0].tools.has("parse_duration")).toBe(true); + }); + + it("registers message renderers", async () => { + const extCode = ` + export default function(pi) { + pi.registerMessageRenderer("my-custom-type", (message, options, theme) => { + return null; // Use default rendering + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true); + }); + + it("reports error when extension throws during initialization", async () => { + const extCode = ` + export default function(pi) { + throw new Error("Initialization failed!"); + } + `; + fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain("Initialization failed!"); + expect(result.extensions).toHaveLength(0); + }); + + it("reports error when extension has no default export", async () => { + const extCode = ` + export function notDefault(pi) { + pi.registerCommand("test", { handler: async () => {} }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain("must export a default function"); + expect(result.extensions).toHaveLength(0); + }); + + it("allows multiple extensions to register different tools", async () => { + fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a")); + fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b")); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(2); + + const allTools = new Set(); + for (const ext of result.extensions) { + for (const name of ext.tools.keys()) { + allTools.add(name); + } + } + expect(allTools.has("tool-a")).toBe(true); + expect(allTools.has("tool-b")).toBe(true); + }); + + it("loads extension with event handlers", async () => { + const extCode = ` + export default function(pi) { + pi.on("agent_start", async () => {}); + pi.on("tool_call", async (event) => undefined); + pi.on("agent_end", async () => {}); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].handlers.has("agent_start")).toBe(true); + expect(result.extensions[0].handlers.has("tool_call")).toBe(true); + expect(result.extensions[0].handlers.has("agent_end")).toBe(true); + }); + + it("loads extension with shortcuts", async () => { + const extCode = ` + export default function(pi) { + pi.registerShortcut("ctrl+t", { + description: "Test shortcut", + handler: async (ctx) => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true); + }); + + it("loads extension with flags", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--my-flag", { + description: "My custom flag", + handler: async (value) => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].flags.has("--my-flag")).toBe(true); + }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts new file mode 100644 index 00000000..6fd4f37e --- /dev/null +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for ExtensionRunner - conflict detection, error handling, tool wrapping. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; +import { ExtensionRunner } from "../src/core/extensions/runner.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; +import { SessionManager } from "../src/core/session-manager.js"; + +describe("ExtensionRunner", () => { + let tempDir: string; + let extensionsDir: string; + let sessionManager: SessionManager; + let modelRegistry: ModelRegistry; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-")); + extensionsDir = path.join(tempDir, "extensions"); + fs.mkdirSync(extensionsDir); + sessionManager = SessionManager.inMemory(); + const authStorage = new AuthStorage(path.join(tempDir, "auth.json")); + modelRegistry = new ModelRegistry(authStorage); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("shortcut conflicts", () => { + it("warns when extension shortcut conflicts with built-in", async () => { + const extCode = ` + export default function(pi) { + pi.registerShortcut("ctrl+c", { + description: "Conflicts with built-in", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "conflict.ts"), extCode); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const shortcuts = runner.getShortcuts(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); + expect(shortcuts.has("ctrl+c")).toBe(false); + + warnSpy.mockRestore(); + }); + + it("warns when two extensions register same shortcut", async () => { + // Use a non-reserved shortcut + const extCode1 = ` + export default function(pi) { + pi.registerShortcut("ctrl+shift+x", { + description: "First extension", + handler: async () => {}, + }); + } + `; + const extCode2 = ` + export default function(pi) { + pi.registerShortcut("ctrl+shift+x", { + description: "Second extension", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "ext1.ts"), extCode1); + fs.writeFileSync(path.join(extensionsDir, "ext2.ts"), extCode2); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const shortcuts = runner.getShortcuts(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict")); + // Last one wins + expect(shortcuts.has("ctrl+shift+x")).toBe(true); + + warnSpy.mockRestore(); + }); + }); + + describe("tool collection", () => { + it("collects tools from multiple extensions", async () => { + const toolCode = (name: string) => ` + import { Type } from "@sinclair/typebox"; + export default function(pi) { + pi.registerTool({ + name: "${name}", + label: "${name}", + description: "Test tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); + fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const tools = runner.getAllRegisteredTools(); + + expect(tools.length).toBe(2); + expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]); + }); + }); + + describe("command collection", () => { + it("collects commands from multiple extensions", async () => { + const cmdCode = (name: string) => ` + export default function(pi) { + pi.registerCommand("${name}", { + description: "Test command", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); + fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const commands = runner.getRegisteredCommands(); + + expect(commands.length).toBe(2); + expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]); + }); + + it("gets command by name", async () => { + const cmdCode = ` + export default function(pi) { + pi.registerCommand("my-cmd", { + description: "My command", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "cmd.ts"), cmdCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const cmd = runner.getCommand("my-cmd"); + expect(cmd).toBeDefined(); + expect(cmd?.name).toBe("my-cmd"); + expect(cmd?.description).toBe("My command"); + + const missing = runner.getCommand("not-exists"); + expect(missing).toBeUndefined(); + }); + }); + + describe("error handling", () => { + it("calls error listeners when handler throws", async () => { + const extCode = ` + export default function(pi) { + pi.on("context", async () => { + throw new Error("Handler error!"); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const errors: Array<{ extensionPath: string; event: string; error: string }> = []; + runner.onError((err) => { + errors.push(err); + }); + + // Emit context event which will trigger the throwing handler + await runner.emitContext([]); + + expect(errors.length).toBe(1); + expect(errors[0].error).toContain("Handler error!"); + expect(errors[0].event).toBe("context"); + }); + }); + + describe("message renderers", () => { + it("gets message renderer by type", async () => { + const extCode = ` + export default function(pi) { + pi.registerMessageRenderer("my-type", (message, options, theme) => null); + } + `; + fs.writeFileSync(path.join(extensionsDir, "renderer.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const renderer = runner.getMessageRenderer("my-type"); + expect(renderer).toBeDefined(); + + const missing = runner.getMessageRenderer("not-exists"); + expect(missing).toBeUndefined(); + }); + }); + + describe("flags", () => { + it("collects flags from extensions", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--my-flag", { + description: "My flag", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const flags = runner.getFlags(); + + expect(flags.has("--my-flag")).toBe(true); + }); + + it("can set flag values", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--test-flag", { + description: "Test flag", + handler: async () => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + // Setting a flag value should not throw + runner.setFlagValue("--test-flag", true); + + // The flag values are stored in the extension's flagValues map + const ext = result.extensions[0]; + expect(ext.flagValues.get("--test-flag")).toBe(true); + }); + }); + + describe("hasHandlers", () => { + it("returns true when handlers exist for event type", async () => { + const extCode = ` + export default function(pi) { + pi.on("tool_call", async () => undefined); + } + `; + fs.writeFileSync(path.join(extensionsDir, "handler.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + expect(runner.hasHandlers("tool_call")).toBe(true); + expect(runner.hasHandlers("agent_end")).toBe(false); + }); + }); +}); diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/prompt-templates.test.ts similarity index 98% rename from packages/coding-agent/test/slash-commands.test.ts rename to packages/coding-agent/test/prompt-templates.test.ts index 7aa7778a..b0d40b2e 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -1,5 +1,5 @@ /** - * Tests for slash command argument parsing and substitution. + * Tests for prompt template argument parsing and substitution. * * Tests verify: * - Argument parsing with quotes and special characters @@ -9,7 +9,7 @@ */ import { describe, expect, test } from "vitest"; -import { parseCommandArgs, substituteArgs } from "../src/core/slash-commands.js"; +import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js"; // ============================================================================ // substituteArgs diff --git a/packages/coding-agent/test/session-manager/migration.test.ts b/packages/coding-agent/test/session-manager/migration.test.ts index 129ba87d..1142da37 100644 --- a/packages/coding-agent/test/session-manager/migration.test.ts +++ b/packages/coding-agent/test/session-manager/migration.test.ts @@ -24,8 +24,8 @@ describe("migrateSessionEntries", () => { migrateSessionEntries(entries); - // Header should have version set - expect((entries[0] as any).version).toBe(2); + // Header should have version set (v3 is current after hookMessage->custom migration) + expect((entries[0] as any).version).toBe(3); // Entries should have id/parentId const msg1 = entries[1] as any; diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts index 45015321..a384a074 100644 --- a/packages/coding-agent/test/session-manager/save-entry.test.ts +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -9,7 +9,7 @@ describe("SessionManager.saveCustomEntry", () => { const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); // Save a custom entry - const customId = session.appendCustomEntry("my_hook", { foo: "bar" }); + const customId = session.appendCustomEntry("my_data", { foo: "bar" }); // Save another message const msg2Id = session.appendMessage({ @@ -36,7 +36,7 @@ describe("SessionManager.saveCustomEntry", () => { const customEntry = entries.find((e) => e.type === "custom") as CustomEntry; expect(customEntry).toBeDefined(); - expect(customEntry.customType).toBe("my_hook"); + expect(customEntry.customType).toBe("my_data"); expect(customEntry.data).toEqual({ foo: "bar" }); expect(customEntry.id).toBe(customId); expect(customEntry.parentId).toBe(msgId); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index fe244710..0a684d7d 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -89,7 +89,7 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const msgId = session.appendMessage(userMsg("hello")); - const customId = session.appendCustomEntry("my_hook", { key: "value" }); + const customId = session.appendCustomEntry("my_data", { key: "value" }); const _msg2Id = session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); @@ -97,7 +97,7 @@ describe("SessionManager append and tree traversal", () => { expect(customEntry).toBeDefined(); expect(customEntry.id).toBe(customId); expect(customEntry.parentId).toBe(msgId); - expect(customEntry.customType).toBe("my_hook"); + expect(customEntry.customType).toBe("my_data"); expect(customEntry.data).toEqual({ key: "value" }); expect(entries[2].parentId).toBe(customId); diff --git a/test.sh b/test.sh new file mode 100755 index 00000000..b4e29b20 --- /dev/null +++ b/test.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +AUTH_FILE="$HOME/.pi/agent/auth.json" +AUTH_BACKUP="$HOME/.pi/agent/auth.json.bak" + +# Restore auth.json on exit (success or failure) +cleanup() { + if [[ -f "$AUTH_BACKUP" ]]; then + mv "$AUTH_BACKUP" "$AUTH_FILE" + echo "Restored auth.json" + fi +} +trap cleanup EXIT + +# Move auth.json out of the way +if [[ -f "$AUTH_FILE" ]]; then + mv "$AUTH_FILE" "$AUTH_BACKUP" + echo "Moved auth.json to backup" +fi + +# Skip local LLM tests (ollama, lmstudio) +export PI_NO_LOCAL_LLM=1 + +# Unset API keys (see packages/ai/src/stream.ts getEnvApiKey) +unset ANTHROPIC_API_KEY +unset ANTHROPIC_OAUTH_TOKEN +unset OPENAI_API_KEY +unset GEMINI_API_KEY +unset GROQ_API_KEY +unset CEREBRAS_API_KEY +unset XAI_API_KEY +unset OPENROUTER_API_KEY +unset ZAI_API_KEY +unset MISTRAL_API_KEY +unset COPILOT_GITHUB_TOKEN +unset GH_TOKEN +unset GITHUB_TOKEN + +echo "Running tests without API keys..." +npm test