mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 09:01:20 +00:00
Add hooks system with pi.send() for external message injection
- Hook discovery from ~/.pi/agent/hooks/, .pi/hooks/, --hook flag - Events: session_start, session_switch, agent_start/end, turn_start/end, tool_call, tool_result, branch - tool_call can block execution, tool_result can modify results - pi.send(text, attachments?) to inject messages from external sources - UI primitives: ctx.ui.select/confirm/input/notify - Context: ctx.exec(), ctx.cwd, ctx.sessionFile, ctx.hasUI - Docs shipped with npm package and binary builds - System prompt references docs folder
This commit is contained in:
parent
942d8d3c95
commit
7c553acd1e
21 changed files with 1307 additions and 83 deletions
38
img-hook.ts
Normal file
38
img-hook.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import type { HookAPI } from "./packages/coding-agent/src/index.js";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const desktop = path.join(os.homedir(), "Desktop");
|
||||||
|
const seen = new Set(fs.readdirSync(desktop).filter((f) => f.endsWith(".png")));
|
||||||
|
|
||||||
|
ctx.ui.notify(`Watching ${desktop} for new .png files`, "info");
|
||||||
|
|
||||||
|
fs.watch(desktop, (event, file) => {
|
||||||
|
if (!file?.endsWith(".png") || event !== "rename" || seen.has(file)) return;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const filePath = path.join(desktop, file);
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
|
||||||
|
seen.add(file);
|
||||||
|
const content = fs.readFileSync(filePath);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
|
pi.send(`Use \`say\` to describe the image. Make it concise and hilarious`, [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "image",
|
||||||
|
fileName: file,
|
||||||
|
mimeType: "image/png",
|
||||||
|
size: stats.size,
|
||||||
|
content: content.toString("base64"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { agentLoop, agentLoopContinue } from "./agent-loop.js";
|
export { agentLoop, agentLoopContinue } from "./agent-loop.js";
|
||||||
export * from "./tools/index.js";
|
export * from "./tools/index.js";
|
||||||
export type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, QueuedMessage } from "./types.js";
|
export type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, AgentToolResult, QueuedMessage } from "./types.js";
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
||||||
- [Custom Models and Providers](#custom-models-and-providers)
|
- [Custom Models and Providers](#custom-models-and-providers)
|
||||||
- [Themes](#themes)
|
- [Themes](#themes)
|
||||||
- [Custom Slash Commands](#custom-slash-commands)
|
- [Custom Slash Commands](#custom-slash-commands)
|
||||||
|
- [Hooks](#hooks)
|
||||||
- [Settings File](#settings-file)
|
- [Settings File](#settings-file)
|
||||||
- [CLI Reference](#cli-reference)
|
- [CLI Reference](#cli-reference)
|
||||||
- [Tools](#tools)
|
- [Tools](#tools)
|
||||||
|
|
@ -461,6 +462,57 @@ Usage: `/component Button "onClick handler" "disabled support"`
|
||||||
|
|
||||||
**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)`
|
**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)`
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. Use them to:
|
||||||
|
|
||||||
|
- **Block dangerous commands** (permission gates for `rm -rf`, `sudo`, etc.)
|
||||||
|
- **Checkpoint code state** (git stash at each turn, restore on `/branch`)
|
||||||
|
- **Protect paths** (block writes to `.env`, `node_modules/`, etc.)
|
||||||
|
- **Modify tool output** (filter or transform results before the LLM sees them)
|
||||||
|
- **Inject messages from external sources** (file watchers, webhooks, CI systems)
|
||||||
|
|
||||||
|
**Hook locations:**
|
||||||
|
- Global: `~/.pi/agent/hooks/*.ts`
|
||||||
|
- Project: `.pi/hooks/*.ts`
|
||||||
|
- CLI: `--hook <path>` (for debugging)
|
||||||
|
|
||||||
|
**Quick example** (permission gate):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) {
|
||||||
|
const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string);
|
||||||
|
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sending messages from hooks:**
|
||||||
|
|
||||||
|
Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async () => {
|
||||||
|
fs.watch("/tmp/trigger.txt", () => {
|
||||||
|
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
|
||||||
|
if (content) pi.send(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Hooks Documentation](docs/hooks.md) for full API reference.
|
||||||
|
|
||||||
### Settings File
|
### Settings File
|
||||||
|
|
||||||
`~/.pi/agent/settings.json` stores persistent preferences:
|
`~/.pi/agent/settings.json` stores persistent preferences:
|
||||||
|
|
@ -504,6 +556,7 @@ pi [options] [@files...] [messages...]
|
||||||
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) |
|
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) |
|
||||||
| `--tools <tools>` | Comma-separated tool list (default: `read,bash,edit,write`) |
|
| `--tools <tools>` | Comma-separated tool list (default: `read,bash,edit,write`) |
|
||||||
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
||||||
|
| `--hook <path>` | Load a hook file (can be used multiple times) |
|
||||||
| `--export <file> [output]` | Export session to HTML |
|
| `--export <file> [output]` | Export session to HTML |
|
||||||
| `--help`, `-h` | Show help |
|
| `--help`, `-h` | Show help |
|
||||||
|
|
||||||
|
|
|
||||||
609
packages/coding-agent/docs/hooks.md
Normal file
609
packages/coding-agent/docs/hooks.md
Normal file
|
|
@ -0,0 +1,609 @@
|
||||||
|
# Hooks
|
||||||
|
|
||||||
|
Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more.
|
||||||
|
|
||||||
|
## Hook Locations
|
||||||
|
|
||||||
|
Hooks are automatically discovered from two locations:
|
||||||
|
|
||||||
|
1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
|
||||||
|
2. **Project hooks**: `<cwd>/.pi/hooks/*.ts`
|
||||||
|
|
||||||
|
All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).
|
||||||
|
|
||||||
|
### Additional Configuration
|
||||||
|
|
||||||
|
You can also add explicit hook paths in `~/.pi/agent/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
"/path/to/custom/hook.ts"
|
||||||
|
],
|
||||||
|
"hookTimeout": 30000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `hooks`: Additional hook file paths (supports `~` expansion)
|
||||||
|
- `hookTimeout`: Timeout in milliseconds for non-interactive hook operations (default: 30000)
|
||||||
|
|
||||||
|
## Writing a Hook
|
||||||
|
|
||||||
|
A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async (event, ctx) => {
|
||||||
|
ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Create a hooks directory and initialize it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Global hooks
|
||||||
|
mkdir -p ~/.pi/agent/hooks
|
||||||
|
cd ~/.pi/agent/hooks
|
||||||
|
npm init -y
|
||||||
|
npm install @mariozechner/pi-coding-agent
|
||||||
|
|
||||||
|
# Or project-local hooks
|
||||||
|
mkdir -p .pi/hooks
|
||||||
|
cd .pi/hooks
|
||||||
|
npm init -y
|
||||||
|
npm install @mariozechner/pi-coding-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
pi starts
|
||||||
|
│
|
||||||
|
├─► session_start
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
user sends prompt ─────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
├─► agent_start │
|
||||||
|
│ │
|
||||||
|
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ├─► turn_start │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ LLM responds, may call tools: │ │
|
||||||
|
│ │ ├─► tool_call (can block) │ │
|
||||||
|
│ │ │ tool executes │ │
|
||||||
|
│ │ └─► tool_result (can modify) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─► turn_end │ │
|
||||||
|
│ │
|
||||||
|
└─► agent_end │
|
||||||
|
│
|
||||||
|
user sends another prompt ◄────────────────────────────────┘
|
||||||
|
|
||||||
|
user branches or switches session
|
||||||
|
│
|
||||||
|
└─► session_switch
|
||||||
|
```
|
||||||
|
|
||||||
|
A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.
|
||||||
|
|
||||||
|
### session_start
|
||||||
|
|
||||||
|
Fired once when pi starts.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("session_start", async (event, ctx) => {
|
||||||
|
// ctx.sessionFile: string | null
|
||||||
|
// ctx.hasUI: boolean
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### session_switch
|
||||||
|
|
||||||
|
Fired when session changes (`/branch` or session switch).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("session_switch", async (event, ctx) => {
|
||||||
|
// event.newSessionFile: string
|
||||||
|
// event.previousSessionFile: string
|
||||||
|
// event.reason: "branch" | "switch"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### agent_start / agent_end
|
||||||
|
|
||||||
|
Fired once per user prompt.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("agent_start", async (event, ctx) => {});
|
||||||
|
|
||||||
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
|
// event.messages: AppMessage[] - new messages from this prompt
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### turn_start / turn_end
|
||||||
|
|
||||||
|
Fired for each turn within an agent loop.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("turn_start", async (event, ctx) => {
|
||||||
|
// event.turnIndex: number
|
||||||
|
// event.timestamp: number
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (event, ctx) => {
|
||||||
|
// event.turnIndex: number
|
||||||
|
// event.message: AppMessage - assistant's response
|
||||||
|
// event.toolResults: AppMessage[]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### tool_call
|
||||||
|
|
||||||
|
Fired before tool executes. **Can block.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
// event.toolName: "bash" | "read" | "write" | "edit" | "ls" | "find" | "grep"
|
||||||
|
// event.toolCallId: string
|
||||||
|
// event.input: Record<string, unknown>
|
||||||
|
return { block: true, reason: "..." }; // or undefined to allow
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool inputs:
|
||||||
|
- `bash`: `{ command, timeout? }`
|
||||||
|
- `read`: `{ path, offset?, limit? }`
|
||||||
|
- `write`: `{ path, content }`
|
||||||
|
- `edit`: `{ path, oldText, newText }`
|
||||||
|
- `ls`: `{ path?, limit? }`
|
||||||
|
- `find`: `{ pattern, path?, limit? }`
|
||||||
|
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`
|
||||||
|
|
||||||
|
### tool_result
|
||||||
|
|
||||||
|
Fired after tool executes. **Can modify result.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
|
// event.toolName, event.toolCallId, event.input
|
||||||
|
// event.result: string
|
||||||
|
// event.isError: boolean
|
||||||
|
return { result: "modified" }; // or undefined to keep original
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### branch
|
||||||
|
|
||||||
|
Fired when user branches via `/branch`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.on("branch", async (event, ctx) => {
|
||||||
|
// event.targetTurnIndex: number
|
||||||
|
// event.entries: SessionEntry[]
|
||||||
|
return { skipConversationRestore: true }; // or undefined
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context API
|
||||||
|
|
||||||
|
Every event handler receives a context object with these methods:
|
||||||
|
|
||||||
|
### ctx.ui.select(title, options)
|
||||||
|
|
||||||
|
Show a selector dialog. Returns the selected option or `null` if cancelled.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
|
||||||
|
if (choice === "Option A") {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.ui.confirm(title, message)
|
||||||
|
|
||||||
|
Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
|
||||||
|
if (confirmed) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.ui.input(title, placeholder?)
|
||||||
|
|
||||||
|
Show a text input dialog. Returns the input string or `null` if cancelled.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const name = await ctx.ui.input("Enter name:", "default value");
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.ui.notify(message, type?)
|
||||||
|
|
||||||
|
Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.ui.notify("Operation complete", "info");
|
||||||
|
ctx.ui.notify("Something went wrong", "error");
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.exec(command, args)
|
||||||
|
|
||||||
|
Execute a command and get the result.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await ctx.exec("git", ["status"]);
|
||||||
|
// result.stdout: string
|
||||||
|
// result.stderr: string
|
||||||
|
// result.code: number
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.cwd
|
||||||
|
|
||||||
|
The current working directory.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(`Working in: ${ctx.cwd}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.sessionFile
|
||||||
|
|
||||||
|
Path to the session file, or `null` if running with `--no-session`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (ctx.sessionFile) {
|
||||||
|
console.log(`Session: ${ctx.sessionFile}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx.hasUI
|
||||||
|
|
||||||
|
Whether interactive UI is available. `false` in print and RPC modes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
const choice = await ctx.ui.select("Pick:", ["A", "B"]);
|
||||||
|
} else {
|
||||||
|
// Fall back to default behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sending Messages
|
||||||
|
|
||||||
|
Hooks can inject messages into the agent session using `pi.send()`. This is useful for:
|
||||||
|
|
||||||
|
- Waking up the agent when an external event occurs (file change, CI result, etc.)
|
||||||
|
- Async debugging (inject debug output from other processes)
|
||||||
|
- Triggering agent actions from external systems
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pi.send(text: string, attachments?: Attachment[]): void
|
||||||
|
```
|
||||||
|
|
||||||
|
If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.
|
||||||
|
|
||||||
|
### Example: File Watcher
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async (event, ctx) => {
|
||||||
|
// Watch a trigger file
|
||||||
|
const triggerFile = "/tmp/agent-trigger.txt";
|
||||||
|
|
||||||
|
fs.watch(triggerFile, () => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
||||||
|
if (content) {
|
||||||
|
pi.send(`External trigger: ${content}`);
|
||||||
|
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File might not exist yet
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To trigger: `echo "Run the tests" > /tmp/agent-trigger.txt`
|
||||||
|
|
||||||
|
### Example: HTTP Webhook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as http from "node:http";
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async (event, ctx) => {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", chunk => body += chunk);
|
||||||
|
req.on("end", () => {
|
||||||
|
pi.send(body || "Webhook triggered");
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("OK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(3333, () => {
|
||||||
|
ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To trigger: `curl -X POST http://localhost:3333 -d "CI build failed"`
|
||||||
|
|
||||||
|
**Note:** `pi.send()` is not supported in print mode (single-shot execution).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Shitty Permission Gate
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
const dangerousPatterns = [
|
||||||
|
/\brm\s+(-rf?|--recursive)/i,
|
||||||
|
/\bsudo\b/i,
|
||||||
|
/\b(chmod|chown)\b.*777/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName !== "bash") return undefined;
|
||||||
|
|
||||||
|
const command = event.input.command as string;
|
||||||
|
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
||||||
|
|
||||||
|
if (isDangerous) {
|
||||||
|
const choice = await ctx.ui.select(
|
||||||
|
`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`,
|
||||||
|
["Yes", "No"]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice !== "Yes") {
|
||||||
|
return { block: true, reason: "Blocked by user" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Checkpointing
|
||||||
|
|
||||||
|
Stash code state at each turn so `/branch` can restore it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
const checkpoints = new Map<number, string>();
|
||||||
|
|
||||||
|
pi.on("turn_start", async (event, ctx) => {
|
||||||
|
// Create a git stash entry before LLM makes changes
|
||||||
|
const { stdout } = await ctx.exec("git", ["stash", "create"]);
|
||||||
|
const ref = stdout.trim();
|
||||||
|
if (ref) {
|
||||||
|
checkpoints.set(event.turnIndex, ref);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("branch", async (event, ctx) => {
|
||||||
|
const ref = checkpoints.get(event.targetTurnIndex);
|
||||||
|
if (!ref) return undefined;
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select("Restore code state?", [
|
||||||
|
"Yes, restore code to that point",
|
||||||
|
"No, keep current code",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (choice?.startsWith("Yes")) {
|
||||||
|
await ctx.exec("git", ["stash", "apply", ref]);
|
||||||
|
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async () => {
|
||||||
|
checkpoints.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Writes to Certain Paths
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName !== "write" && event.toolName !== "edit") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = event.input.path as string;
|
||||||
|
const isProtected = protectedPaths.some((p) => path.includes(p));
|
||||||
|
|
||||||
|
if (isProtected) {
|
||||||
|
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
||||||
|
return { block: true, reason: `Path "${path}" is protected` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mode Behavior
|
||||||
|
|
||||||
|
Hooks behave differently depending on the run mode:
|
||||||
|
|
||||||
|
| Mode | UI Methods | Notes |
|
||||||
|
|------|-----------|-------|
|
||||||
|
| Interactive | Full TUI dialogs | User can interact normally |
|
||||||
|
| RPC | JSON protocol | Host application handles UI |
|
||||||
|
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
|
||||||
|
|
||||||
|
In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If a hook throws an error, it's logged and the agent continues
|
||||||
|
- If a `tool_call` hook errors or times out, the tool is **blocked** (fail-safe)
|
||||||
|
- Hook errors are displayed in the UI with the hook path and error message
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
To debug a hook:
|
||||||
|
|
||||||
|
1. Open VS Code in your hooks directory
|
||||||
|
2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
|
||||||
|
3. Set breakpoints in your hook file
|
||||||
|
4. Run `pi --hook ./my-hook.ts` in the debug terminal
|
||||||
|
|
||||||
|
The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Internals
|
||||||
|
|
||||||
|
## Discovery and Loading
|
||||||
|
|
||||||
|
Hooks are discovered and loaded at startup in `main.ts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
main.ts
|
||||||
|
-> discoverAndLoadHooks(configuredPaths, cwd) [loader.ts]
|
||||||
|
-> discoverHooksInDir(~/.pi/agent/hooks/) # global hooks
|
||||||
|
-> discoverHooksInDir(cwd/.pi/hooks/) # project hooks
|
||||||
|
-> merge with configuredPaths (deduplicated)
|
||||||
|
-> for each path:
|
||||||
|
-> jiti.import(path) # TypeScript support via jiti
|
||||||
|
-> hookFactory(hookAPI) # calls pi.on() to register handlers
|
||||||
|
-> returns LoadedHook { path, handlers: Map<eventType, handlers[]> }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Wrapping
|
||||||
|
|
||||||
|
Tools are wrapped with hook callbacks before the agent is created:
|
||||||
|
|
||||||
|
```
|
||||||
|
main.ts
|
||||||
|
-> wrapToolsWithHooks(tools, hookRunner) [tool-wrapper.ts]
|
||||||
|
-> returns new tools with wrapped execute() functions
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapped `execute()` function:
|
||||||
|
|
||||||
|
1. Checks `hookRunner.hasHandlers("tool_call")`
|
||||||
|
2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
|
||||||
|
3. If result has `block: true`, throws an error
|
||||||
|
4. Otherwise, calls the original `tool.execute()`
|
||||||
|
5. Checks `hookRunner.hasHandlers("tool_result")`
|
||||||
|
6. If yes, calls `hookRunner.emit(event)` (with timeout)
|
||||||
|
7. Returns (possibly modified) result
|
||||||
|
|
||||||
|
## HookRunner
|
||||||
|
|
||||||
|
The `HookRunner` class manages hook execution:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class HookRunner {
|
||||||
|
constructor(hooks: LoadedHook[], cwd: string, timeout?: number)
|
||||||
|
|
||||||
|
setUIContext(ctx: HookUIContext, hasUI: boolean): void
|
||||||
|
setSessionFile(path: string | null): void
|
||||||
|
onError(listener): () => void
|
||||||
|
hasHandlers(eventType: string): boolean
|
||||||
|
emit(event: HookEvent): Promise<Result>
|
||||||
|
emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- `emit()` has a timeout (default 30s) for safety
|
||||||
|
- `emitToolCall()` has **no timeout** (user prompts can take any amount of time)
|
||||||
|
- Errors in `emit()` are caught and reported via `onError()`
|
||||||
|
- Errors in `emitToolCall()` propagate (causing tool to be blocked)
|
||||||
|
|
||||||
|
## Event Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Mode initialization:
|
||||||
|
-> hookRunner.setUIContext(ctx, hasUI)
|
||||||
|
-> hookRunner.setSessionFile(path)
|
||||||
|
-> hookRunner.emit({ type: "session_start" })
|
||||||
|
|
||||||
|
User sends prompt:
|
||||||
|
-> AgentSession.prompt()
|
||||||
|
-> hookRunner.emit({ type: "agent_start" })
|
||||||
|
-> hookRunner.emit({ type: "turn_start", turnIndex })
|
||||||
|
-> agent loop:
|
||||||
|
-> LLM generates tool calls
|
||||||
|
-> For each tool call:
|
||||||
|
-> wrappedTool.execute()
|
||||||
|
-> hookRunner.emitToolCall({ type: "tool_call", ... })
|
||||||
|
-> [if not blocked] originalTool.execute()
|
||||||
|
-> hookRunner.emit({ type: "tool_result", ... })
|
||||||
|
-> LLM generates response
|
||||||
|
-> hookRunner.emit({ type: "turn_end", ... })
|
||||||
|
-> [repeat if more tool calls]
|
||||||
|
-> hookRunner.emit({ type: "agent_end", messages })
|
||||||
|
|
||||||
|
Branch or session switch:
|
||||||
|
-> AgentSession.branch() or AgentSession.switchSession()
|
||||||
|
-> hookRunner.emit({ type: "session_switch", ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Context by Mode
|
||||||
|
|
||||||
|
Each mode provides its own `HookUIContext` implementation:
|
||||||
|
|
||||||
|
**Interactive Mode** (`interactive-mode.ts`):
|
||||||
|
- `select()` -> `HookSelectorComponent` (TUI list selector)
|
||||||
|
- `confirm()` -> `HookSelectorComponent` with Yes/No options
|
||||||
|
- `input()` -> `HookInputComponent` (TUI text input)
|
||||||
|
- `notify()` -> Adds text to chat container
|
||||||
|
|
||||||
|
**RPC Mode** (`rpc-mode.ts`):
|
||||||
|
- All methods send JSON requests via stdout
|
||||||
|
- Waits for JSON responses via stdin
|
||||||
|
- Host application renders UI and sends responses
|
||||||
|
|
||||||
|
**Print Mode** (`print-mode.ts`):
|
||||||
|
- All methods return null/false immediately
|
||||||
|
- `notify()` is a no-op
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/coding-agent/src/core/hooks/
|
||||||
|
├── index.ts # Public exports
|
||||||
|
├── types.ts # Event types, HookAPI, contexts
|
||||||
|
├── loader.ts # jiti-based hook loading
|
||||||
|
├── runner.ts # HookRunner class
|
||||||
|
└── tool-wrapper.ts # Tool wrapping for interception
|
||||||
|
```
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
"docs",
|
||||||
"CHANGELOG.md"
|
"CHANGELOG.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
|
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
|
||||||
"build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
"build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
||||||
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/",
|
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/",
|
||||||
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/",
|
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/",
|
||||||
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||||
"check": "tsgo --noEmit",
|
"check": "tsgo --noEmit",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface Args {
|
||||||
session?: string;
|
session?: string;
|
||||||
models?: string[];
|
models?: string[];
|
||||||
tools?: ToolName[];
|
tools?: ToolName[];
|
||||||
|
hooks?: string[];
|
||||||
print?: boolean;
|
print?: boolean;
|
||||||
export?: string;
|
export?: string;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
|
|
@ -100,6 +101,9 @@ export function parseArgs(args: string[]): Args {
|
||||||
result.print = true;
|
result.print = true;
|
||||||
} else if (arg === "--export" && i + 1 < args.length) {
|
} else if (arg === "--export" && i + 1 < args.length) {
|
||||||
result.export = args[++i];
|
result.export = args[++i];
|
||||||
|
} else if (arg === "--hook" && i + 1 < args.length) {
|
||||||
|
result.hooks = result.hooks ?? [];
|
||||||
|
result.hooks.push(args[++i]);
|
||||||
} else if (arg.startsWith("@")) {
|
} else if (arg.startsWith("@")) {
|
||||||
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||||
} else if (!arg.startsWith("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
|
|
@ -132,6 +136,7 @@ ${chalk.bold("Options:")}
|
||||||
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
|
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
|
||||||
Available: read, bash, edit, write, grep, find, ls
|
Available: read, bash, edit, write, grep, find, ls
|
||||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
||||||
|
--hook <path> Load a hook file (can be used multiple times)
|
||||||
--export <file> Export session file to HTML and exit
|
--export <file> Export session file to HTML and exit
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ export function getReadmePath(): string {
|
||||||
return resolve(join(getPackageDir(), "README.md"));
|
return resolve(join(getPackageDir(), "README.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get path to docs directory */
|
||||||
|
export function getDocsPath(): string {
|
||||||
|
return resolve(join(getPackageDir(), "docs"));
|
||||||
|
}
|
||||||
|
|
||||||
/** Get path to CHANGELOG.md */
|
/** Get path to CHANGELOG.md */
|
||||||
export function getChangelogPath(): string {
|
export function getChangelogPath(): string {
|
||||||
return resolve(join(getPackageDir(), "CHANGELOG.md"));
|
return resolve(join(getPackageDir(), "CHANGELOG.md"));
|
||||||
|
|
|
||||||
|
|
@ -888,6 +888,8 @@ export class AgentSession {
|
||||||
* Listeners are preserved and will continue receiving events.
|
* Listeners are preserved and will continue receiving events.
|
||||||
*/
|
*/
|
||||||
async switchSession(sessionPath: string): Promise<void> {
|
async switchSession(sessionPath: string): Promise<void> {
|
||||||
|
const previousSessionFile = this.sessionFile;
|
||||||
|
|
||||||
this._disconnectFromAgent();
|
this._disconnectFromAgent();
|
||||||
await this.abort();
|
await this.abort();
|
||||||
this._queuedMessages = [];
|
this._queuedMessages = [];
|
||||||
|
|
@ -895,6 +897,17 @@ export class AgentSession {
|
||||||
// Set new session
|
// Set new session
|
||||||
this.sessionManager.setSessionFile(sessionPath);
|
this.sessionManager.setSessionFile(sessionPath);
|
||||||
|
|
||||||
|
// Emit session_switch event
|
||||||
|
if (this._hookRunner) {
|
||||||
|
this._hookRunner.setSessionFile(sessionPath);
|
||||||
|
await this._hookRunner.emit({
|
||||||
|
type: "session_switch",
|
||||||
|
newSessionFile: sessionPath,
|
||||||
|
previousSessionFile,
|
||||||
|
reason: "switch",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Reload messages
|
// Reload messages
|
||||||
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
||||||
this.agent.replaceMessages(loaded.messages);
|
this.agent.replaceMessages(loaded.messages);
|
||||||
|
|
@ -928,6 +941,7 @@ export class AgentSession {
|
||||||
* - skipped: True if a hook requested to skip conversation restore
|
* - skipped: True if a hook requested to skip conversation restore
|
||||||
*/
|
*/
|
||||||
async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> {
|
async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> {
|
||||||
|
const previousSessionFile = this.sessionFile;
|
||||||
const entries = this.sessionManager.loadEntries();
|
const entries = this.sessionManager.loadEntries();
|
||||||
const selectedEntry = entries[entryIndex];
|
const selectedEntry = entries[entryIndex];
|
||||||
|
|
||||||
|
|
@ -956,6 +970,17 @@ export class AgentSession {
|
||||||
const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
|
const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
|
||||||
this.sessionManager.setSessionFile(newSessionFile);
|
this.sessionManager.setSessionFile(newSessionFile);
|
||||||
|
|
||||||
|
// Emit session_switch event
|
||||||
|
if (this._hookRunner) {
|
||||||
|
this._hookRunner.setSessionFile(newSessionFile);
|
||||||
|
await this._hookRunner.emit({
|
||||||
|
type: "session_switch",
|
||||||
|
newSessionFile,
|
||||||
|
previousSessionFile,
|
||||||
|
reason: "branch",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Reload
|
// Reload
|
||||||
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
||||||
this.agent.replaceMessages(loaded.messages);
|
this.agent.replaceMessages(loaded.messages);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { type LoadedHook, type LoadHooksResult, loadHooks } from "./loader.js";
|
export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js";
|
||||||
export { type HookErrorListener, HookRunner } from "./runner.js";
|
export { type HookErrorListener, HookRunner } from "./runner.js";
|
||||||
|
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
||||||
export type {
|
export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
|
|
@ -12,6 +13,12 @@ export type {
|
||||||
HookEventContext,
|
HookEventContext,
|
||||||
HookFactory,
|
HookFactory,
|
||||||
HookUIContext,
|
HookUIContext,
|
||||||
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEvent,
|
||||||
|
ToolResultEventResult,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnStartEvent,
|
TurnStartEvent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
* Hook loader - loads TypeScript hook modules using jiti.
|
* Hook loader - loads TypeScript hook modules using jiti.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||||
import { createJiti } from "jiti";
|
import { createJiti } from "jiti";
|
||||||
|
import { getAgentDir } from "../../config.js";
|
||||||
import type { HookAPI, HookFactory } from "./types.js";
|
import type { HookAPI, HookFactory } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,6 +15,11 @@ import type { HookAPI, HookFactory } from "./types.js";
|
||||||
*/
|
*/
|
||||||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send handler type for pi.send().
|
||||||
|
*/
|
||||||
|
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered handlers for a loaded hook.
|
* Registered handlers for a loaded hook.
|
||||||
*/
|
*/
|
||||||
|
|
@ -22,6 +30,8 @@ export interface LoadedHook {
|
||||||
resolvedPath: string;
|
resolvedPath: string;
|
||||||
/** Map of event type to handler functions */
|
/** Map of event type to handler functions */
|
||||||
handlers: Map<string, HandlerFn[]>;
|
handlers: Map<string, HandlerFn[]>;
|
||||||
|
/** Set the send handler for this hook's pi.send() */
|
||||||
|
setSendHandler: (handler: SendHandler) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,15 +76,33 @@ function resolveHookPath(hookPath: string, cwd: string): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a HookAPI instance that collects handlers.
|
* Create a HookAPI instance that collects handlers.
|
||||||
|
* Returns the API and a function to set the send handler later.
|
||||||
*/
|
*/
|
||||||
function createHookAPI(handlers: Map<string, HandlerFn[]>): HookAPI {
|
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
|
||||||
return {
|
api: HookAPI;
|
||||||
|
setSendHandler: (handler: SendHandler) => void;
|
||||||
|
} {
|
||||||
|
let sendHandler: SendHandler = () => {
|
||||||
|
// Default no-op until mode sets the handler
|
||||||
|
};
|
||||||
|
|
||||||
|
const api: HookAPI = {
|
||||||
on(event: string, handler: HandlerFn): void {
|
on(event: string, handler: HandlerFn): void {
|
||||||
const list = handlers.get(event) ?? [];
|
const list = handlers.get(event) ?? [];
|
||||||
list.push(handler);
|
list.push(handler);
|
||||||
handlers.set(event, list);
|
handlers.set(event, list);
|
||||||
},
|
},
|
||||||
|
send(text: string, attachments?: Attachment[]): void {
|
||||||
|
sendHandler(text, attachments);
|
||||||
|
},
|
||||||
} as HookAPI;
|
} as HookAPI;
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
setSendHandler: (handler: SendHandler) => {
|
||||||
|
sendHandler = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,13 +125,13 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
||||||
|
|
||||||
// Create handlers map and API
|
// Create handlers map and API
|
||||||
const handlers = new Map<string, HandlerFn[]>();
|
const handlers = new Map<string, HandlerFn[]>();
|
||||||
const api = createHookAPI(handlers);
|
const { api, setSendHandler } = createHookAPI(handlers);
|
||||||
|
|
||||||
// Call factory to register handlers
|
// Call factory to register handlers
|
||||||
factory(api);
|
factory(api);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hook: { path: hookPath, resolvedPath, handlers },
|
hook: { path: hookPath, resolvedPath, handlers, setSendHandler },
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -136,3 +164,59 @@ export async function loadHooks(paths: string[], cwd: string): Promise<LoadHooks
|
||||||
|
|
||||||
return { hooks, errors };
|
return { hooks, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover hook files from a directory.
|
||||||
|
* Returns all .ts files in the directory (non-recursive).
|
||||||
|
*/
|
||||||
|
function discoverHooksInDir(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries.filter((e) => e.isFile() && e.name.endsWith(".ts")).map((e) => path.join(dir, e.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover and load hooks from standard locations:
|
||||||
|
* 1. ~/.pi/agent/hooks/*.ts (global)
|
||||||
|
* 2. cwd/.pi/hooks/*.ts (project-local)
|
||||||
|
*
|
||||||
|
* Plus any explicitly configured paths from settings.
|
||||||
|
*
|
||||||
|
* @param configuredPaths - Explicit paths from settings.json
|
||||||
|
* @param cwd - Current working directory
|
||||||
|
*/
|
||||||
|
export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise<LoadHooksResult> {
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// Helper to add paths without duplicates
|
||||||
|
const addPaths = (paths: string[]) => {
|
||||||
|
for (const p of paths) {
|
||||||
|
const resolved = path.resolve(p);
|
||||||
|
if (!seen.has(resolved)) {
|
||||||
|
seen.add(resolved);
|
||||||
|
allPaths.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Global hooks: ~/.pi/agent/hooks/
|
||||||
|
const globalHooksDir = path.join(getAgentDir(), "hooks");
|
||||||
|
addPaths(discoverHooksInDir(globalHooksDir));
|
||||||
|
|
||||||
|
// 2. Project-local hooks: cwd/.pi/hooks/
|
||||||
|
const localHooksDir = path.join(cwd, ".pi", "hooks");
|
||||||
|
addPaths(discoverHooksInDir(localHooksDir));
|
||||||
|
|
||||||
|
// 3. Explicitly configured paths (can override/add)
|
||||||
|
addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd)));
|
||||||
|
|
||||||
|
return loadHooks(allPaths, cwd);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,18 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { LoadedHook } from "./loader.js";
|
import type { LoadedHook, SendHandler } from "./loader.js";
|
||||||
import type { BranchEventResult, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext } from "./types.js";
|
import type {
|
||||||
|
BranchEventResult,
|
||||||
|
ExecResult,
|
||||||
|
HookError,
|
||||||
|
HookEvent,
|
||||||
|
HookEventContext,
|
||||||
|
HookUIContext,
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEventResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default timeout for hook execution (30 seconds).
|
* Default timeout for hook execution (30 seconds).
|
||||||
|
|
@ -58,23 +68,68 @@ function createTimeout(ms: number): { promise: Promise<never>; clear: () => void
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No-op UI context used when no UI is available */
|
||||||
|
const noOpUIContext: HookUIContext = {
|
||||||
|
select: async () => null,
|
||||||
|
confirm: async () => false,
|
||||||
|
input: async () => null,
|
||||||
|
notify: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HookRunner executes hooks and manages event emission.
|
* HookRunner executes hooks and manages event emission.
|
||||||
*/
|
*/
|
||||||
export class HookRunner {
|
export class HookRunner {
|
||||||
private hooks: LoadedHook[];
|
private hooks: LoadedHook[];
|
||||||
private uiContext: HookUIContext;
|
private uiContext: HookUIContext;
|
||||||
|
private hasUI: boolean;
|
||||||
private cwd: string;
|
private cwd: string;
|
||||||
|
private sessionFile: string | null;
|
||||||
private timeout: number;
|
private timeout: number;
|
||||||
private errorListeners: Set<HookErrorListener> = new Set();
|
private errorListeners: Set<HookErrorListener> = new Set();
|
||||||
|
|
||||||
constructor(hooks: LoadedHook[], uiContext: HookUIContext, cwd: string, timeout: number = DEFAULT_TIMEOUT) {
|
constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
this.uiContext = uiContext;
|
this.uiContext = noOpUIContext;
|
||||||
|
this.hasUI = false;
|
||||||
this.cwd = cwd;
|
this.cwd = cwd;
|
||||||
|
this.sessionFile = null;
|
||||||
this.timeout = timeout;
|
this.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the UI context for hooks.
|
||||||
|
* Call this when the mode initializes and UI is available.
|
||||||
|
*/
|
||||||
|
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
|
||||||
|
this.uiContext = uiContext;
|
||||||
|
this.hasUI = hasUI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the paths of all loaded hooks.
|
||||||
|
*/
|
||||||
|
getHookPaths(): string[] {
|
||||||
|
return this.hooks.map((h) => h.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the session file path.
|
||||||
|
*/
|
||||||
|
setSessionFile(sessionFile: string | null): void {
|
||||||
|
this.sessionFile = sessionFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the send handler for all hooks' pi.send().
|
||||||
|
* Call this when the mode initializes.
|
||||||
|
*/
|
||||||
|
setSendHandler(handler: SendHandler): void {
|
||||||
|
for (const hook of this.hooks) {
|
||||||
|
hook.setSendHandler(handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to hook errors.
|
* Subscribe to hook errors.
|
||||||
* @returns Unsubscribe function
|
* @returns Unsubscribe function
|
||||||
|
|
@ -113,17 +168,19 @@ export class HookRunner {
|
||||||
return {
|
return {
|
||||||
exec: (command: string, args: string[]) => exec(command, args, this.cwd),
|
exec: (command: string, args: string[]) => exec(command, args, this.cwd),
|
||||||
ui: this.uiContext,
|
ui: this.uiContext,
|
||||||
|
hasUI: this.hasUI,
|
||||||
cwd: this.cwd,
|
cwd: this.cwd,
|
||||||
|
sessionFile: this.sessionFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit an event to all hooks.
|
* Emit an event to all hooks.
|
||||||
* Returns the result from branch events (if any handler returns one).
|
* Returns the result from branch/tool_result events (if any handler returns one).
|
||||||
*/
|
*/
|
||||||
async emit(event: HookEvent): Promise<BranchEventResult | undefined> {
|
async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
|
||||||
const ctx = this.createContext();
|
const ctx = this.createContext();
|
||||||
let result: BranchEventResult | undefined;
|
let result: BranchEventResult | ToolResultEventResult | undefined;
|
||||||
|
|
||||||
for (const hook of this.hooks) {
|
for (const hook of this.hooks) {
|
||||||
const handlers = hook.handlers.get(event.type);
|
const handlers = hook.handlers.get(event.type);
|
||||||
|
|
@ -132,15 +189,18 @@ export class HookRunner {
|
||||||
for (const handler of handlers) {
|
for (const handler of handlers) {
|
||||||
try {
|
try {
|
||||||
const timeout = createTimeout(this.timeout);
|
const timeout = createTimeout(this.timeout);
|
||||||
|
|
||||||
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
||||||
|
|
||||||
timeout.clear();
|
timeout.clear();
|
||||||
|
|
||||||
// For branch events, capture the result
|
// For branch events, capture the result
|
||||||
if (event.type === "branch" && handlerResult) {
|
if (event.type === "branch" && handlerResult) {
|
||||||
result = handlerResult as BranchEventResult;
|
result = handlerResult as BranchEventResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For tool_result events, capture the result
|
||||||
|
if (event.type === "tool_result" && handlerResult) {
|
||||||
|
result = handlerResult as ToolResultEventResult;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.emitError({
|
this.emitError({
|
||||||
|
|
@ -154,4 +214,34 @@ export class HookRunner {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a tool_call event to all hooks.
|
||||||
|
* No timeout - user prompts can take as long as needed.
|
||||||
|
* Errors are thrown (not swallowed) so caller can block on failure.
|
||||||
|
*/
|
||||||
|
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
|
||||||
|
const ctx = this.createContext();
|
||||||
|
let result: ToolCallEventResult | undefined;
|
||||||
|
|
||||||
|
for (const hook of this.hooks) {
|
||||||
|
const handlers = hook.handlers.get("tool_call");
|
||||||
|
if (!handlers || handlers.length === 0) continue;
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
// No timeout - let user take their time
|
||||||
|
const handlerResult = await handler(event, ctx);
|
||||||
|
|
||||||
|
if (handlerResult) {
|
||||||
|
result = handlerResult as ToolCallEventResult;
|
||||||
|
// If blocked, stop processing further hooks
|
||||||
|
if (result.block) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
packages/coding-agent/src/core/hooks/tool-wrapper.ts
Normal file
81
packages/coding-agent/src/core/hooks/tool-wrapper.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Tool wrapper - wraps tools with hook callbacks for interception.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
|
import type { HookRunner } from "./runner.js";
|
||||||
|
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a tool with hook callbacks.
|
||||||
|
* - Emits tool_call event before execution (can block)
|
||||||
|
* - Emits tool_result event after execution (can modify result)
|
||||||
|
*/
|
||||||
|
export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRunner): AgentTool<any, T> {
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
execute: async (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal) => {
|
||||||
|
// Emit tool_call event - hooks can block execution
|
||||||
|
// If hook errors/times out, block by default (fail-safe)
|
||||||
|
if (hookRunner.hasHandlers("tool_call")) {
|
||||||
|
try {
|
||||||
|
const callResult = (await hookRunner.emitToolCall({
|
||||||
|
type: "tool_call",
|
||||||
|
toolName: tool.name,
|
||||||
|
toolCallId,
|
||||||
|
input: params,
|
||||||
|
})) as ToolCallEventResult | undefined;
|
||||||
|
|
||||||
|
if (callResult?.block) {
|
||||||
|
const reason = callResult.reason || "Tool execution was blocked by a hook";
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Hook error or block - throw to mark as error
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error(`Hook failed, blocking execution: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the actual tool
|
||||||
|
const result = await tool.execute(toolCallId, params, signal);
|
||||||
|
|
||||||
|
// Emit tool_result event - hooks can modify the result
|
||||||
|
if (hookRunner.hasHandlers("tool_result")) {
|
||||||
|
// Extract text from result for hooks
|
||||||
|
const resultText = result.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const resultResult = (await hookRunner.emit({
|
||||||
|
type: "tool_result",
|
||||||
|
toolName: tool.name,
|
||||||
|
toolCallId,
|
||||||
|
input: params,
|
||||||
|
result: resultText,
|
||||||
|
isError: false,
|
||||||
|
})) as ToolResultEventResult | undefined;
|
||||||
|
|
||||||
|
// Apply modifications if any
|
||||||
|
if (resultResult?.result !== undefined) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
content: [{ type: "text", text: resultResult.result }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap all tools with hook callbacks.
|
||||||
|
*/
|
||||||
|
export function wrapToolsWithHooks<T>(tools: AgentTool<any, T>[], hookRunner: HookRunner): AgentTool<any, T>[] {
|
||||||
|
return tools.map((tool) => wrapToolWithHooks(tool, hookRunner));
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* and interact with the user via UI primitives.
|
* and interact with the user via UI primitives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
|
||||||
import type { SessionEntry } from "../session-manager.js";
|
import type { SessionEntry } from "../session-manager.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -60,16 +60,43 @@ export interface HookEventContext {
|
||||||
exec(command: string, args: string[]): Promise<ExecResult>;
|
exec(command: string, args: string[]): Promise<ExecResult>;
|
||||||
/** UI methods for user interaction */
|
/** UI methods for user interaction */
|
||||||
ui: HookUIContext;
|
ui: HookUIContext;
|
||||||
|
/** Whether UI is available (false in print mode) */
|
||||||
|
hasUI: boolean;
|
||||||
/** Current working directory */
|
/** Current working directory */
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** Path to session file, or null if --no-session */
|
||||||
|
sessionFile: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Events
|
// Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for session_start event.
|
||||||
|
* Fired once when the coding agent starts up.
|
||||||
|
*/
|
||||||
|
export interface SessionStartEvent {
|
||||||
|
type: "session_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for session_switch event.
|
||||||
|
* Fired when the session changes (branch or session switch).
|
||||||
|
*/
|
||||||
|
export interface SessionSwitchEvent {
|
||||||
|
type: "session_switch";
|
||||||
|
/** New session file path */
|
||||||
|
newSessionFile: string;
|
||||||
|
/** Previous session file path */
|
||||||
|
previousSessionFile: string;
|
||||||
|
/** Reason for the switch */
|
||||||
|
reason: "branch" | "switch";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for agent_start event.
|
* Event data for agent_start event.
|
||||||
|
* Fired when an agent loop starts (once per user prompt).
|
||||||
*/
|
*/
|
||||||
export interface AgentStartEvent {
|
export interface AgentStartEvent {
|
||||||
type: "agent_start";
|
type: "agent_start";
|
||||||
|
|
@ -102,6 +129,38 @@ export interface TurnEndEvent {
|
||||||
toolResults: AppMessage[];
|
toolResults: AppMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for tool_call event.
|
||||||
|
* Fired before a tool is executed. Hooks can block execution.
|
||||||
|
*/
|
||||||
|
export interface ToolCallEvent {
|
||||||
|
type: "tool_call";
|
||||||
|
/** Tool name (e.g., "bash", "edit", "write") */
|
||||||
|
toolName: string;
|
||||||
|
/** Tool call ID */
|
||||||
|
toolCallId: string;
|
||||||
|
/** Tool input parameters */
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for tool_result event.
|
||||||
|
* Fired after a tool is executed. Hooks can modify the result.
|
||||||
|
*/
|
||||||
|
export interface ToolResultEvent {
|
||||||
|
type: "tool_result";
|
||||||
|
/** Tool name (e.g., "bash", "edit", "write") */
|
||||||
|
toolName: string;
|
||||||
|
/** Tool call ID */
|
||||||
|
toolCallId: string;
|
||||||
|
/** Tool input parameters */
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
/** Tool result content (text) */
|
||||||
|
result: string;
|
||||||
|
/** Whether the tool execution was an error */
|
||||||
|
isError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for branch event.
|
* Event data for branch event.
|
||||||
*/
|
*/
|
||||||
|
|
@ -116,12 +175,43 @@ export interface BranchEvent {
|
||||||
/**
|
/**
|
||||||
* Union of all hook event types.
|
* Union of all hook event types.
|
||||||
*/
|
*/
|
||||||
export type HookEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | BranchEvent;
|
export type HookEvent =
|
||||||
|
| SessionStartEvent
|
||||||
|
| SessionSwitchEvent
|
||||||
|
| AgentStartEvent
|
||||||
|
| AgentEndEvent
|
||||||
|
| TurnStartEvent
|
||||||
|
| TurnEndEvent
|
||||||
|
| ToolCallEvent
|
||||||
|
| ToolResultEvent
|
||||||
|
| BranchEvent;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Event Results
|
// Event Results
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for tool_call event handlers.
|
||||||
|
* Allows hooks to block tool execution.
|
||||||
|
*/
|
||||||
|
export interface ToolCallEventResult {
|
||||||
|
/** If true, block the tool from executing */
|
||||||
|
block?: boolean;
|
||||||
|
/** Reason for blocking (returned to LLM as error) */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for tool_result event handlers.
|
||||||
|
* Allows hooks to modify tool results.
|
||||||
|
*/
|
||||||
|
export interface ToolResultEventResult {
|
||||||
|
/** Modified result text (if not set, original result is used) */
|
||||||
|
result?: string;
|
||||||
|
/** Override isError flag */
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return type for branch event handlers.
|
* Return type for branch event handlers.
|
||||||
* Allows hooks to control branch behavior.
|
* Allows hooks to control branch behavior.
|
||||||
|
|
@ -142,14 +232,25 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HookAPI passed to hook factory functions.
|
* HookAPI passed to hook factory functions.
|
||||||
* Hooks use pi.on() to subscribe to events.
|
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
|
||||||
*/
|
*/
|
||||||
export interface HookAPI {
|
export interface HookAPI {
|
||||||
|
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
|
||||||
|
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
|
||||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||||
|
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
|
||||||
|
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
|
||||||
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
|
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the agent.
|
||||||
|
* If the agent is streaming, the message is queued.
|
||||||
|
* If the agent is idle, a new agent loop is started.
|
||||||
|
*/
|
||||||
|
send(text: string, attachments?: Attachment[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import { getAgentDir, getReadmePath } from "../config.js";
|
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
||||||
import type { ToolName } from "./tools/index.js";
|
import type { ToolName } from "./tools/index.js";
|
||||||
|
|
||||||
/** Tool descriptions for system prompt */
|
/** Tool descriptions for system prompt */
|
||||||
|
|
@ -148,8 +148,9 @@ export function buildSystemPrompt(
|
||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get absolute path to README.md
|
// Get absolute paths to documentation
|
||||||
const readmePath = getReadmePath();
|
const readmePath = getReadmePath();
|
||||||
|
const docsPath = getDocsPath();
|
||||||
|
|
||||||
// Build tools list based on selected tools
|
// Build tools list based on selected tools
|
||||||
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
||||||
|
|
@ -223,7 +224,8 @@ ${guidelines}
|
||||||
|
|
||||||
Documentation:
|
Documentation:
|
||||||
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
|
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
|
||||||
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;
|
- Additional documentation (hooks, themes, RPC, etc.) is in: ${docsPath}
|
||||||
|
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, create a custom theme, or write a hook.`;
|
||||||
|
|
||||||
if (appendSection) {
|
if (appendSection) {
|
||||||
prompt += appendSection;
|
prompt += appendSection;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ export type {
|
||||||
HookEventContext,
|
HookEventContext,
|
||||||
HookFactory,
|
HookFactory,
|
||||||
HookUIContext,
|
HookUIContext,
|
||||||
|
SessionStartEvent,
|
||||||
|
SessionSwitchEvent,
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolCallEventResult,
|
||||||
|
ToolResultEvent,
|
||||||
|
ToolResultEventResult,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnStartEvent,
|
TurnStartEvent,
|
||||||
} from "./core/hooks/index.js";
|
} from "./core/hooks/index.js";
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ import { selectSession } from "./cli/session-picker.js";
|
||||||
import { getModelsPath, VERSION } from "./config.js";
|
import { getModelsPath, VERSION } from "./config.js";
|
||||||
import { AgentSession } from "./core/agent-session.js";
|
import { AgentSession } from "./core/agent-session.js";
|
||||||
import { exportFromFile } from "./core/export-html.js";
|
import { exportFromFile } from "./core/export-html.js";
|
||||||
|
import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js";
|
||||||
import { messageTransformer } from "./core/messages.js";
|
import { messageTransformer } from "./core/messages.js";
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
|
||||||
import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js";
|
import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js";
|
||||||
import { SessionManager } from "./core/session-manager.js";
|
import { SessionManager } from "./core/session-manager.js";
|
||||||
import { SettingsManager } from "./core/settings-manager.js";
|
import { SettingsManager } from "./core/settings-manager.js";
|
||||||
import { loadSlashCommands } from "./core/slash-commands.js";
|
import { loadSlashCommands } from "./core/slash-commands.js";
|
||||||
import { buildSystemPrompt, loadProjectContextFiles } from "./core/system-prompt.js";
|
import { buildSystemPrompt } from "./core/system-prompt.js";
|
||||||
import { allTools, codingTools } from "./core/tools/index.js";
|
import { allTools, codingTools } from "./core/tools/index.js";
|
||||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||||
import { initTheme } from "./modes/interactive/theme/theme.js";
|
import { initTheme } from "./modes/interactive/theme/theme.js";
|
||||||
|
|
@ -270,7 +271,30 @@ export async function main(args: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which tools to use
|
// Determine which tools to use
|
||||||
const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
|
let selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
|
||||||
|
|
||||||
|
// Discover and load hooks from:
|
||||||
|
// 1. ~/.pi/agent/hooks/*.ts (global)
|
||||||
|
// 2. cwd/.pi/hooks/*.ts (project-local)
|
||||||
|
// 3. Explicit paths in settings.json
|
||||||
|
// 4. CLI --hook flags
|
||||||
|
let hookRunner: HookRunner | null = null;
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const configuredHookPaths = [...settingsManager.getHookPaths(), ...(parsed.hooks ?? [])];
|
||||||
|
const { hooks, errors } = await discoverAndLoadHooks(configuredHookPaths, cwd);
|
||||||
|
|
||||||
|
// Report hook loading errors
|
||||||
|
for (const { path, error } of errors) {
|
||||||
|
console.error(chalk.red(`Failed to load hook "${path}": ${error}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hooks.length > 0) {
|
||||||
|
const timeout = settingsManager.getHookTimeout();
|
||||||
|
hookRunner = new HookRunner(hooks, cwd, timeout);
|
||||||
|
|
||||||
|
// Wrap tools with hook callbacks
|
||||||
|
selectedTools = wrapToolsWithHooks(selectedTools, hookRunner);
|
||||||
|
}
|
||||||
|
|
||||||
// Create agent
|
// Create agent
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
|
|
@ -317,17 +341,6 @@ export async function main(args: string[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log loaded context files
|
|
||||||
if (shouldPrintMessages && !parsed.continue && !parsed.resume) {
|
|
||||||
const contextFiles = loadProjectContextFiles();
|
|
||||||
if (contextFiles.length > 0) {
|
|
||||||
console.log(chalk.dim("Loaded project context from:"));
|
|
||||||
for (const { path: filePath } of contextFiles) {
|
|
||||||
console.log(chalk.dim(` - ${filePath}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load file commands for slash command expansion
|
// Load file commands for slash command expansion
|
||||||
const fileCommands = loadSlashCommands();
|
const fileCommands = loadSlashCommands();
|
||||||
|
|
||||||
|
|
@ -338,6 +351,7 @@ export async function main(args: string[]) {
|
||||||
settingsManager,
|
settingsManager,
|
||||||
scopedModels,
|
scopedModels,
|
||||||
fileCommands,
|
fileCommands,
|
||||||
|
hookRunner,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route to appropriate mode
|
// Route to appropriate mode
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
||||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +30,7 @@ import { isBashExecutionMessage } from "../../core/messages.js";
|
||||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
|
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
|
||||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
||||||
|
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
|
import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
|
||||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||||
|
|
@ -276,24 +277,43 @@ export class InteractiveMode {
|
||||||
* Initialize the hook system with TUI-based UI context.
|
* Initialize the hook system with TUI-based UI context.
|
||||||
*/
|
*/
|
||||||
private async initHooks(): Promise<void> {
|
private async initHooks(): Promise<void> {
|
||||||
const hookPaths = this.settingsManager.getHookPaths();
|
// Show loaded project context files
|
||||||
if (hookPaths.length === 0) {
|
const contextFiles = loadProjectContextFiles();
|
||||||
return; // No hooks configured
|
if (contextFiles.length > 0) {
|
||||||
|
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create hook UI context
|
const hookRunner = this.session.hookRunner;
|
||||||
const hookUIContext = this.createHookUIContext();
|
if (!hookRunner) {
|
||||||
|
return; // No hooks loaded
|
||||||
|
}
|
||||||
|
|
||||||
// Set context on session
|
// Set TUI-based UI context on the hook runner
|
||||||
this.session.setHookUIContext(hookUIContext, (error) => {
|
hookRunner.setUIContext(this.createHookUIContext(), true);
|
||||||
|
hookRunner.setSessionFile(this.session.sessionFile);
|
||||||
|
|
||||||
|
// Subscribe to hook errors
|
||||||
|
hookRunner.onError((error) => {
|
||||||
this.showHookError(error.hookPath, error.error);
|
this.showHookError(error.hookPath, error.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize hooks and report any loading errors
|
// Set up send handler for pi.send()
|
||||||
const loadErrors = await this.session.initHooks();
|
hookRunner.setSendHandler((text, attachments) => {
|
||||||
for (const { path, error } of loadErrors) {
|
this.handleHookSend(text, attachments);
|
||||||
this.showHookError(path, error);
|
});
|
||||||
|
|
||||||
|
// Show loaded hooks
|
||||||
|
const hookPaths = hookRunner.getHookPaths();
|
||||||
|
if (hookPaths.length > 0) {
|
||||||
|
const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit session_start event
|
||||||
|
await hookRunner.emit({ type: "session_start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -392,10 +412,13 @@ export class InteractiveMode {
|
||||||
* Show a notification for hooks.
|
* Show a notification for hooks.
|
||||||
*/
|
*/
|
||||||
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
|
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
|
||||||
const color = type === "error" ? "error" : type === "warning" ? "warning" : "dim";
|
if (type === "error") {
|
||||||
const text = new Text(theme.fg(color, `[Hook] ${message}`), 1, 0);
|
this.showError(message);
|
||||||
this.chatContainer.addChild(text);
|
} else if (type === "warning") {
|
||||||
this.ui.requestRender();
|
this.showWarning(message);
|
||||||
|
} else {
|
||||||
|
this.showStatus(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -407,6 +430,23 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pi.send() from hooks.
|
||||||
|
* If streaming, queue the message. Otherwise, start a new agent loop.
|
||||||
|
*/
|
||||||
|
private handleHookSend(text: string, attachments?: Attachment[]): void {
|
||||||
|
if (this.session.isStreaming) {
|
||||||
|
// Queue the message for later (note: attachments are lost when queuing)
|
||||||
|
this.session.queueMessage(text);
|
||||||
|
this.updatePendingMessagesDisplay();
|
||||||
|
} else {
|
||||||
|
// Start a new agent loop immediately
|
||||||
|
this.session.prompt(text, { attachments }).catch((err) => {
|
||||||
|
this.showError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Key Handlers
|
// Key Handlers
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,6 @@
|
||||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import type { AgentSession } from "../core/agent-session.js";
|
import type { AgentSession } from "../core/agent-session.js";
|
||||||
import type { HookUIContext } from "../core/hooks/index.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a no-op hook UI context for print mode.
|
|
||||||
* Hooks can still run but can't prompt the user interactively.
|
|
||||||
*/
|
|
||||||
function createNoOpHookUIContext(): HookUIContext {
|
|
||||||
return {
|
|
||||||
async select() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
async confirm() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
async input() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
notify() {
|
|
||||||
// Silent in print mode
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run in print (single-shot) mode.
|
* Run in print (single-shot) mode.
|
||||||
|
|
@ -49,11 +27,21 @@ export async function runPrintMode(
|
||||||
initialMessage?: string,
|
initialMessage?: string,
|
||||||
initialAttachments?: Attachment[],
|
initialAttachments?: Attachment[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Initialize hooks with no-op UI context (hooks run but can't prompt)
|
// Hook runner already has no-op UI context by default (set in main.ts)
|
||||||
session.setHookUIContext(createNoOpHookUIContext(), (err) => {
|
// Set up hooks for print mode (no UI, ephemeral session)
|
||||||
console.error(`Hook error (${err.hookPath}): ${err.error}`);
|
const hookRunner = session.hookRunner;
|
||||||
});
|
if (hookRunner) {
|
||||||
await session.initHooks();
|
hookRunner.setSessionFile(null); // Print mode is ephemeral
|
||||||
|
hookRunner.onError((err) => {
|
||||||
|
console.error(`Hook error (${err.hookPath}): ${err.error}`);
|
||||||
|
});
|
||||||
|
// No-op send handler for print mode (single-shot, no async messages)
|
||||||
|
hookRunner.setSendHandler(() => {
|
||||||
|
console.error("Warning: pi.send() is not supported in print mode");
|
||||||
|
});
|
||||||
|
// Emit session_start event
|
||||||
|
await hookRunner.emit({ type: "session_start" });
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "json") {
|
if (mode === "json") {
|
||||||
// Output all events as JSON
|
// Output all events as JSON
|
||||||
|
|
|
||||||
|
|
@ -121,11 +121,27 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up hooks with RPC-based UI context
|
// Set up hooks with RPC-based UI context
|
||||||
const hookUIContext = createHookUIContext();
|
const hookRunner = session.hookRunner;
|
||||||
session.setHookUIContext(hookUIContext, (err) => {
|
if (hookRunner) {
|
||||||
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
|
hookRunner.setUIContext(createHookUIContext(), false);
|
||||||
});
|
hookRunner.setSessionFile(session.sessionFile);
|
||||||
await session.initHooks();
|
hookRunner.onError((err) => {
|
||||||
|
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
|
||||||
|
});
|
||||||
|
// Set up send handler for pi.send()
|
||||||
|
hookRunner.setSendHandler((text, attachments) => {
|
||||||
|
// In RPC mode, just queue or prompt based on streaming state
|
||||||
|
if (session.isStreaming) {
|
||||||
|
session.queueMessage(text);
|
||||||
|
} else {
|
||||||
|
session.prompt(text, { attachments }).catch((e) => {
|
||||||
|
output(error(undefined, "hook_send", e.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Emit session_start event
|
||||||
|
await hookRunner.emit({ type: "session_start" });
|
||||||
|
}
|
||||||
|
|
||||||
// Output all agent events as JSON
|
// Output all agent events as JSON
|
||||||
session.subscribe((event) => {
|
session.subscribe((event) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
{
|
{
|
||||||
"name": "pi-mono",
|
"name": "pi-mono",
|
||||||
"path": "."
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../.pi/hooks"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|
|
||||||
56
test-hook.ts
Normal file
56
test-hook.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import type { HookAPI } from "./packages/coding-agent/src/index.js";
|
||||||
|
|
||||||
|
export default function (pi: HookAPI) {
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
ctx.ui.notify("Test hook loaded!", "info");
|
||||||
|
|
||||||
|
// Set up a file watcher to demonstrate pi.send()
|
||||||
|
const triggerFile = "/tmp/pi-trigger.txt";
|
||||||
|
|
||||||
|
// Create empty trigger file if it doesn't exist
|
||||||
|
if (!fs.existsSync(triggerFile)) {
|
||||||
|
fs.writeFileSync(triggerFile, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.watch(triggerFile, () => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
||||||
|
if (content) {
|
||||||
|
pi.send(`[External trigger]: ${content}`);
|
||||||
|
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File might be in flux
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.ui.notify("Watching /tmp/pi-trigger.txt for external messages", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
console.log(`[test-hook] tool_call: ${event.toolName}`);
|
||||||
|
|
||||||
|
// Example: block dangerous bash commands
|
||||||
|
if (event.toolName === "bash") {
|
||||||
|
const cmd = event.input.command as string;
|
||||||
|
if (/rm\s+-rf/.test(cmd)) {
|
||||||
|
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}?`);
|
||||||
|
if (!ok) {
|
||||||
|
return { block: true, reason: "User blocked rm -rf" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_result", async (event, _ctx) => {
|
||||||
|
console.log(`[test-hook] tool_result: ${event.toolName} (${event.result.length} chars)`);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (event, _ctx) => {
|
||||||
|
console.log(`[test-hook] turn_end: turn ${event.turnIndex}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue