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:
Mario Zechner 2025-12-10 00:50:30 +01:00
parent 942d8d3c95
commit 7c553acd1e
21 changed files with 1307 additions and 83 deletions

View file

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

View file

@ -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<unknown>;
/**
* 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<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.
* Returns the API and a function to set the send handler later.
*/
function createHookAPI(handlers: Map<string, HandlerFn[]>): HookAPI {
return {
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
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<string, HandlerFn[]>();
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<LoadHooks
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);
}

View file

@ -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<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.
*/
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<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.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<BranchEventResult | undefined> {
async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
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<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;
}
}

View 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));
}

View file

@ -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<ExecResult>;
/** 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<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.
*/
@ -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<E, R = void> = (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<SessionStartEvent>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): 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;
/**
* 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;
}
/**