Merge hooks and custom-tools into unified extensions system (#454)

Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
This commit is contained in:
Mario Zechner 2026-01-05 01:43:35 +01:00
parent 9794868b38
commit c6fc084534
112 changed files with 2842 additions and 6747 deletions

View file

@ -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)

View file

@ -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")])
```

View file

@ -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;

View file

@ -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 <goal>` |
| `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
}
}
});
```

View file

@ -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"]);

View file

@ -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`);
});
}

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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";

View file

@ -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<string, string>();
let currentEntryId: string | undefined;

View file

@ -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) => {

View file

@ -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 },
};
},
});
}

View file

@ -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) => {

View file

@ -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

View file

@ -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 = [];

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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<typeof QuestionParams, QuestionDetails> = {
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;
});
}

View file

@ -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!",

View file

@ -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) => {

View file

@ -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 <query>` | scout → planner → worker |
| `/scout-and-plan <query>` | scout → planner |
| `/implement-and-review <query>` | worker → reviewer → worker |

View file

@ -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<SubagentDetails>) => 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<number>((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<typeof SubagentParams, SubagentDetails> = {
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<string>();
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;
});
}

View file

@ -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<typeof TodoParams, TodoDetails> = {
// 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<void>((_tui, theme, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}

View file

@ -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<string> = 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

View file

@ -0,0 +1 @@
node_modules/

View file

@ -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: {},
};
},
});
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}

View file

@ -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 <goal>` |
| `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");
},
});
}
```

View file

@ -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<void>((_tui, theme, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}

View file

@ -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.
*/

View file

@ -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"]

View file

@ -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/
* - <cwd>/.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}\`);
},
});
}
*/

View file

@ -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"]

View file

@ -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: []

View file

@ -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: []

View file

@ -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();

View file

@ -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 |