mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
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:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
```
|
||||
|
|
@ -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;
|
||||
141
packages/coding-agent/examples/extensions/README.md
Normal file
141
packages/coding-agent/examples/extensions/README.md
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
@ -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"]);
|
||||
26
packages/coding-agent/examples/extensions/chalk-logger.ts
Normal file
26
packages/coding-agent/examples/extensions/chalk-logger.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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) => {
|
||||
21
packages/coding-agent/examples/extensions/hello/index.ts
Normal file
21
packages/coding-agent/examples/extensions/hello/index.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = [];
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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!",
|
||||
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
1
packages/coding-agent/examples/extensions/with-deps/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/with-deps/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
40
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal file
40
packages/coding-agent/examples/extensions/with-deps/index.ts
Normal 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: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
31
packages/coding-agent/examples/extensions/with-deps/package-lock.json
generated
Normal file
31
packages/coding-agent/examples/extensions/with-deps/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
|
@ -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());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
81
packages/coding-agent/examples/sdk/06-extensions.ts
Normal file
81
packages/coding-agent/examples/sdk/06-extensions.ts
Normal 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}\`);
|
||||
},
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
|
@ -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"]
|
||||
42
packages/coding-agent/examples/sdk/08-prompt-templates.ts
Normal file
42
packages/coding-agent/examples/sdk/08-prompt-templates.ts
Normal 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: []
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue