From 7c553acd1ef0eff99a53e2fdfcbc5687c8893e03 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 10 Dec 2025 00:50:30 +0100 Subject: [PATCH] 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 --- img-hook.ts | 38 ++ packages/ai/src/agent/index.ts | 2 +- packages/coding-agent/README.md | 53 ++ packages/coding-agent/docs/hooks.md | 609 ++++++++++++++++++ packages/coding-agent/package.json | 3 +- packages/coding-agent/src/cli/args.ts | 5 + packages/coding-agent/src/config.ts | 5 + .../coding-agent/src/core/agent-session.ts | 25 + packages/coding-agent/src/core/hooks/index.ts | 9 +- .../coding-agent/src/core/hooks/loader.ts | 92 ++- .../coding-agent/src/core/hooks/runner.ts | 108 +++- .../src/core/hooks/tool-wrapper.ts | 81 +++ packages/coding-agent/src/core/hooks/types.ts | 107 ++- .../coding-agent/src/core/system-prompt.ts | 8 +- packages/coding-agent/src/index.ts | 6 + packages/coding-agent/src/main.ts | 40 +- .../src/modes/interactive/interactive-mode.ts | 72 ++- packages/coding-agent/src/modes/print-mode.ts | 42 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 26 +- pi-mono.code-workspace | 3 + test-hook.ts | 56 ++ 21 files changed, 1307 insertions(+), 83 deletions(-) create mode 100644 img-hook.ts create mode 100644 packages/coding-agent/docs/hooks.md create mode 100644 packages/coding-agent/src/core/hooks/tool-wrapper.ts create mode 100644 test-hook.ts diff --git a/img-hook.ts b/img-hook.ts new file mode 100644 index 00000000..3cc4040a --- /dev/null +++ b/img-hook.ts @@ -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); + }); + }); +} diff --git a/packages/ai/src/agent/index.ts b/packages/ai/src/agent/index.ts index 57d64212..289ad327 100644 --- a/packages/ai/src/agent/index.ts +++ b/packages/ai/src/agent/index.ts @@ -1,3 +1,3 @@ export { agentLoop, agentLoopContinue } from "./agent-loop.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"; diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 8b98d990..eb6091cb 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -26,6 +26,7 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Custom Models and Providers](#custom-models-and-providers) - [Themes](#themes) - [Custom Slash Commands](#custom-slash-commands) + - [Hooks](#hooks) - [Settings File](#settings-file) - [CLI Reference](#cli-reference) - [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)` +### 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 ` (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 `~/.pi/agent/settings.json` stores persistent preferences: @@ -504,6 +556,7 @@ pi [options] [@files...] [messages...] | `--models ` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) | | `--tools ` | Comma-separated tool list (default: `read,bash,edit,write`) | | `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` | +| `--hook ` | Load a hook file (can be used multiple times) | | `--export [output]` | Export session to HTML | | `--help`, `-h` | Show help | diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md new file mode 100644 index 00000000..fb0eebcc --- /dev/null +++ b/packages/coding-agent/docs/hooks.md @@ -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**: `/.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 + 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(); + + 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 } +``` + +## 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 + emitToolCall(event: ToolCallEvent): Promise +} +``` + +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 +``` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 90b827cd..ba8d7833 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -14,6 +14,7 @@ "types": "./dist/index.d.ts", "files": [ "dist", + "docs", "CHANGELOG.md" ], "scripts": { @@ -21,7 +22,7 @@ "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", "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", "check": "tsgo --noEmit", "test": "vitest --run", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 4a2a0192..8ff34cd2 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -24,6 +24,7 @@ export interface Args { session?: string; models?: string[]; tools?: ToolName[]; + hooks?: string[]; print?: boolean; export?: string; messages: string[]; @@ -100,6 +101,9 @@ export function parseArgs(args: string[]): Args { result.print = true; } else if (arg === "--export" && i + 1 < args.length) { 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("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix } else if (!arg.startsWith("-")) { @@ -132,6 +136,7 @@ ${chalk.bold("Options:")} --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh + --hook Load a hook file (can be used multiple times) --export Export session file to HTML and exit --help, -h Show this help diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index fd0232e1..401d82f1 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -70,6 +70,11 @@ export function getReadmePath(): string { 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 */ export function getChangelogPath(): string { return resolve(join(getPackageDir(), "CHANGELOG.md")); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 88810f7b..b55d4030 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -888,6 +888,8 @@ export class AgentSession { * Listeners are preserved and will continue receiving events. */ async switchSession(sessionPath: string): Promise { + const previousSessionFile = this.sessionFile; + this._disconnectFromAgent(); await this.abort(); this._queuedMessages = []; @@ -895,6 +897,17 @@ export class AgentSession { // Set new session 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 const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); this.agent.replaceMessages(loaded.messages); @@ -928,6 +941,7 @@ export class AgentSession { * - skipped: True if a hook requested to skip conversation restore */ async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> { + const previousSessionFile = this.sessionFile; const entries = this.sessionManager.loadEntries(); const selectedEntry = entries[entryIndex]; @@ -956,6 +970,17 @@ export class AgentSession { const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex); 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 const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); this.agent.replaceMessages(loaded.messages); diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index cd0a1ee6..8cf1be90 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -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 { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, @@ -12,6 +13,12 @@ export type { HookEventContext, HookFactory, HookUIContext, + SessionStartEvent, + SessionSwitchEvent, + ToolCallEvent, + ToolCallEventResult, + ToolResultEvent, + ToolResultEventResult, TurnEndEvent, TurnStartEvent, } from "./types.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index e0e6f805..c86e13da 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -2,9 +2,12 @@ * Hook loader - loads TypeScript hook modules using jiti. */ +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import type { Attachment } from "@mariozechner/pi-agent-core"; import { createJiti } from "jiti"; +import { getAgentDir } from "../../config.js"; import type { HookAPI, HookFactory } from "./types.js"; /** @@ -12,6 +15,11 @@ import type { HookAPI, HookFactory } from "./types.js"; */ type HandlerFn = (...args: unknown[]) => Promise; +/** + * Send handler type for pi.send(). + */ +export type SendHandler = (text: string, attachments?: Attachment[]) => void; + /** * Registered handlers for a loaded hook. */ @@ -22,6 +30,8 @@ export interface LoadedHook { resolvedPath: string; /** Map of event type to handler functions */ handlers: Map; + /** 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. + * Returns the API and a function to set the send handler later. */ -function createHookAPI(handlers: Map): HookAPI { - return { +function createHookAPI(handlers: Map): { + 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 { const list = handlers.get(event) ?? []; list.push(handler); handlers.set(event, list); }, + send(text: string, attachments?: Attachment[]): void { + sendHandler(text, attachments); + }, } 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 const handlers = new Map(); - const api = createHookAPI(handlers); + const { api, setSendHandler } = createHookAPI(handlers); // Call factory to register handlers factory(api); return { - hook: { path: hookPath, resolvedPath, handlers }, + hook: { path: hookPath, resolvedPath, handlers, setSendHandler }, error: null, }; } catch (err) { @@ -136,3 +164,59 @@ export async function loadHooks(paths: string[], cwd: string): Promise 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 { + const allPaths: string[] = []; + const seen = new Set(); + + // Helper to add paths without duplicates + const addPaths = (paths: string[]) => { + for (const p of paths) { + const resolved = path.resolve(p); + if (!seen.has(resolved)) { + seen.add(resolved); + allPaths.push(p); + } + } + }; + + // 1. Global hooks: ~/.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); +} diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index b77038a0..505276ce 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,8 +3,18 @@ */ import { spawn } from "node:child_process"; -import type { LoadedHook } from "./loader.js"; -import type { BranchEventResult, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext } from "./types.js"; +import type { LoadedHook, SendHandler } from "./loader.js"; +import type { + BranchEventResult, + ExecResult, + HookError, + HookEvent, + HookEventContext, + HookUIContext, + ToolCallEvent, + ToolCallEventResult, + ToolResultEventResult, +} from "./types.js"; /** * Default timeout for hook execution (30 seconds). @@ -58,23 +68,68 @@ function createTimeout(ms: number): { promise: Promise; 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. */ export class HookRunner { private hooks: LoadedHook[]; private uiContext: HookUIContext; + private hasUI: boolean; private cwd: string; + private sessionFile: string | null; private timeout: number; private errorListeners: Set = 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.uiContext = uiContext; + this.uiContext = noOpUIContext; + this.hasUI = false; this.cwd = cwd; + this.sessionFile = null; 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. * @returns Unsubscribe function @@ -113,17 +168,19 @@ export class HookRunner { return { exec: (command: string, args: string[]) => exec(command, args, this.cwd), ui: this.uiContext, + hasUI: this.hasUI, cwd: this.cwd, + sessionFile: this.sessionFile, }; } /** * 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 { + async emit(event: HookEvent): Promise { const ctx = this.createContext(); - let result: BranchEventResult | undefined; + let result: BranchEventResult | ToolResultEventResult | undefined; for (const hook of this.hooks) { const handlers = hook.handlers.get(event.type); @@ -132,15 +189,18 @@ export class HookRunner { for (const handler of handlers) { try { const timeout = createTimeout(this.timeout); - const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); // For branch events, capture the result if (event.type === "branch" && handlerResult) { result = handlerResult as BranchEventResult; } + + // For tool_result events, capture the result + if (event.type === "tool_result" && handlerResult) { + result = handlerResult as ToolResultEventResult; + } } catch (err) { const message = err instanceof Error ? err.message : String(err); this.emitError({ @@ -154,4 +214,34 @@ export class HookRunner { return result; } + + /** + * Emit a tool_call event to all hooks. + * No timeout - user prompts can take as long as needed. + * Errors are thrown (not swallowed) so caller can block on failure. + */ + async emitToolCall(event: ToolCallEvent): Promise { + const ctx = this.createContext(); + let result: ToolCallEventResult | undefined; + + for (const hook of this.hooks) { + const handlers = hook.handlers.get("tool_call"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + // No timeout - let user take their time + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + result = handlerResult as ToolCallEventResult; + // If blocked, stop processing further hooks + if (result.block) { + return result; + } + } + } + } + + return result; + } } diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts new file mode 100644 index 00000000..f4eb6b07 --- /dev/null +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -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(tool: AgentTool, hookRunner: HookRunner): AgentTool { + return { + ...tool, + execute: async (toolCallId: string, params: Record, 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(tools: AgentTool[], hookRunner: HookRunner): AgentTool[] { + return tools.map((tool) => wrapToolWithHooks(tool, hookRunner)); +} diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index af91aa26..f70ed693 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -5,7 +5,7 @@ * 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"; // ============================================================================ @@ -60,16 +60,43 @@ export interface HookEventContext { exec(command: string, args: string[]): Promise; /** UI methods for user interaction */ ui: HookUIContext; + /** Whether UI is available (false in print mode) */ + hasUI: boolean; /** Current working directory */ cwd: string; + /** Path to session file, or null if --no-session */ + sessionFile: string | null; } // ============================================================================ // 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. + * Fired when an agent loop starts (once per user prompt). */ export interface AgentStartEvent { type: "agent_start"; @@ -102,6 +129,38 @@ export interface TurnEndEvent { 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; +} + +/** + * 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; + /** Tool result content (text) */ + result: string; + /** Whether the tool execution was an error */ + isError: boolean; +} + /** * Event data for branch event. */ @@ -116,12 +175,43 @@ export interface BranchEvent { /** * 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 // ============================================================================ +/** + * 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. * Allows hooks to control branch behavior. @@ -142,14 +232,25 @@ export type HookHandler = (event: E, ctx: HookEventContext) => Prom /** * 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 { + on(event: "session_start", handler: HookHandler): void; + on(event: "session_switch", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; on(event: "turn_end", handler: HookHandler): void; + on(event: "tool_call", handler: HookHandler): void; + on(event: "tool_result", handler: HookHandler): void; on(event: "branch", handler: HookHandler): 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; } /** diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index c622e3b3..305f4912 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -5,7 +5,7 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; 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"; /** Tool descriptions for system prompt */ @@ -148,8 +148,9 @@ export function buildSystemPrompt( return prompt; } - // Get absolute path to README.md + // Get absolute paths to documentation const readmePath = getReadmePath(); + const docsPath = getDocsPath(); // Build tools list based on selected tools const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); @@ -223,7 +224,8 @@ ${guidelines} Documentation: - 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) { prompt += appendSection; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index d223a754..0b127081 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -9,6 +9,12 @@ export type { HookEventContext, HookFactory, HookUIContext, + SessionStartEvent, + SessionSwitchEvent, + ToolCallEvent, + ToolCallEventResult, + ToolResultEvent, + ToolResultEventResult, TurnEndEvent, TurnStartEvent, } from "./core/hooks/index.js"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f999a748..7f484c4c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,13 +10,14 @@ import { selectSession } from "./cli/session-picker.js"; import { getModelsPath, VERSION } from "./config.js"; import { AgentSession } from "./core/agent-session.js"; import { exportFromFile } from "./core/export-html.js"; +import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js"; import { messageTransformer } from "./core/messages.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js"; import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js"; import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.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 { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; import { initTheme } from "./modes/interactive/theme/theme.js"; @@ -270,7 +271,30 @@ export async function main(args: string[]) { } // 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 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 const fileCommands = loadSlashCommands(); @@ -338,6 +351,7 @@ export async function main(args: string[]) { settingsManager, scopedModels, fileCommands, + hookRunner, }); // Route to appropriate mode diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1a4a1697..946130a6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; 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 { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -30,6 +30,7 @@ import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.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 { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; @@ -276,24 +277,43 @@ export class InteractiveMode { * Initialize the hook system with TUI-based UI context. */ private async initHooks(): Promise { - const hookPaths = this.settingsManager.getHookPaths(); - if (hookPaths.length === 0) { - return; // No hooks configured + // Show loaded project context files + const contextFiles = loadProjectContextFiles(); + 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 hookUIContext = this.createHookUIContext(); + const hookRunner = this.session.hookRunner; + if (!hookRunner) { + return; // No hooks loaded + } - // Set context on session - this.session.setHookUIContext(hookUIContext, (error) => { + // Set TUI-based UI context on the hook runner + hookRunner.setUIContext(this.createHookUIContext(), true); + hookRunner.setSessionFile(this.session.sessionFile); + + // Subscribe to hook errors + hookRunner.onError((error) => { this.showHookError(error.hookPath, error.error); }); - // Initialize hooks and report any loading errors - const loadErrors = await this.session.initHooks(); - for (const { path, error } of loadErrors) { - this.showHookError(path, error); + // Set up send handler for pi.send() + hookRunner.setSendHandler((text, attachments) => { + this.handleHookSend(text, attachments); + }); + + // 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. */ private showHookNotify(message: string, type?: "info" | "warning" | "error"): void { - const color = type === "error" ? "error" : type === "warning" ? "warning" : "dim"; - const text = new Text(theme.fg(color, `[Hook] ${message}`), 1, 0); - this.chatContainer.addChild(text); - this.ui.requestRender(); + if (type === "error") { + this.showError(message); + } else if (type === "warning") { + this.showWarning(message); + } else { + this.showStatus(message); + } } /** @@ -407,6 +430,23 @@ export class InteractiveMode { 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 // ========================================================================= diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index a386a1c6..cc11d846 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,28 +9,6 @@ import type { Attachment } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; 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. @@ -49,11 +27,21 @@ export async function runPrintMode( initialMessage?: string, initialAttachments?: Attachment[], ): Promise { - // Initialize hooks with no-op UI context (hooks run but can't prompt) - session.setHookUIContext(createNoOpHookUIContext(), (err) => { - console.error(`Hook error (${err.hookPath}): ${err.error}`); - }); - await session.initHooks(); + // Hook runner already has no-op UI context by default (set in main.ts) + // Set up hooks for print mode (no UI, ephemeral session) + const hookRunner = session.hookRunner; + if (hookRunner) { + 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") { // Output all events as JSON diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 83408f3b..827dbb98 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -121,11 +121,27 @@ export async function runRpcMode(session: AgentSession): Promise { }); // Set up hooks with RPC-based UI context - const hookUIContext = createHookUIContext(); - session.setHookUIContext(hookUIContext, (err) => { - output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); - }); - await session.initHooks(); + const hookRunner = session.hookRunner; + if (hookRunner) { + hookRunner.setUIContext(createHookUIContext(), false); + hookRunner.setSessionFile(session.sessionFile); + 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 session.subscribe((event) => { diff --git a/pi-mono.code-workspace b/pi-mono.code-workspace index dc380bb7..f4900287 100644 --- a/pi-mono.code-workspace +++ b/pi-mono.code-workspace @@ -3,6 +3,9 @@ { "name": "pi-mono", "path": "." + }, + { + "path": "../../.pi/hooks" } ], "settings": {} diff --git a/test-hook.ts b/test-hook.ts new file mode 100644 index 00000000..2e502719 --- /dev/null +++ b/test-hook.ts @@ -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}`); + }); +}