feat(coding-agent): implement hooks system

- Add hooks infrastructure in core/hooks/ (loader, runner, types)
- HookUIContext interface with mode-specific implementations
- Interactive mode: TUI-based selector/input/confirm dialogs
- RPC mode: JSON protocol for hook UI requests/responses
- Print mode: no-op UI context (hooks run but can't prompt)
- AgentSession.branch() now async, returns { selectedText, skipped }
- Settings: hooks[] and hookTimeout configuration
- Export hook types from package for hook authors

Based on PR #147 proposal, adapted for new architecture.
This commit is contained in:
Mario Zechner 2025-12-09 22:17:12 +01:00
parent 195760d8ee
commit 04d59f31ea
17 changed files with 1264 additions and 126 deletions

View file

@ -20,6 +20,15 @@ import { getModelsPath } from "../config.js";
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
import { exportSessionToHtml } from "./export-html.js";
import {
type BranchEventResult,
type HookError,
HookRunner,
type HookUIContext,
loadHooks,
type TurnEndEvent,
type TurnStartEvent,
} from "./hooks/index.js";
import type { BashExecutionMessage } from "./messages.js";
import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { loadSessionFromEntries, type SessionManager } from "./session-manager.js";
@ -47,6 +56,10 @@ export interface AgentSessionConfig {
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** File-based slash commands for expansion */
fileCommands?: FileSlashCommand[];
/** UI context for hooks. If not provided, hooks are disabled. */
hookUIContext?: HookUIContext;
/** Callback for hook errors */
onHookError?: (error: HookError) => void;
}
/** Options for AgentSession.prompt() */
@ -117,12 +130,21 @@ export class AgentSession {
private _bashAbortController: AbortController | null = null;
private _pendingBashMessages: BashExecutionMessage[] = [];
// Hook system
private _hookRunner: HookRunner | null = null;
private _hookUIContext?: HookUIContext;
private _onHookError?: (error: HookError) => void;
private _hooksInitialized = false;
private _turnIndex = 0;
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? [];
this._fileCommands = config.fileCommands ?? [];
this._hookUIContext = config.hookUIContext;
this._onHookError = config.onHookError;
}
// =========================================================================
@ -141,6 +163,9 @@ export class AgentSession {
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// Emit to hooks first
await this._emitHookEvent(event);
// Notify all listeners
this._emit(event);
@ -167,14 +192,83 @@ export class AgentSession {
}
};
/** Emit hook events based on agent events */
private async _emitHookEvent(event: AgentEvent): Promise<void> {
if (!this._hookRunner) return;
if (event.type === "agent_start") {
this._turnIndex = 0;
await this._hookRunner.emit({ type: "agent_start" });
} else if (event.type === "agent_end") {
await this._hookRunner.emit({ type: "agent_end", messages: event.messages });
} else if (event.type === "turn_start") {
const hookEvent: TurnStartEvent = {
type: "turn_start",
turnIndex: this._turnIndex,
timestamp: Date.now(),
};
await this._hookRunner.emit(hookEvent);
} else if (event.type === "turn_end") {
const hookEvent: TurnEndEvent = {
type: "turn_end",
turnIndex: this._turnIndex,
message: event.message,
toolResults: event.toolResults,
};
await this._hookRunner.emit(hookEvent);
this._turnIndex++;
}
}
/**
* Initialize hooks from settings.
* Called automatically on first subscribe, but can be called manually earlier.
* Returns any errors encountered during hook loading.
*/
async initHooks(): Promise<Array<{ path: string; error: string }>> {
if (this._hooksInitialized) return [];
this._hooksInitialized = true;
// Skip if no UI context (hooks disabled)
if (!this._hookUIContext) return [];
const hookPaths = this.settingsManager.getHookPaths();
if (hookPaths.length === 0) return [];
const cwd = process.cwd();
const { hooks, errors } = await loadHooks(hookPaths, cwd);
if (hooks.length > 0) {
const timeout = this.settingsManager.getHookTimeout();
this._hookRunner = new HookRunner(hooks, this._hookUIContext, cwd, timeout);
// Subscribe to hook errors
if (this._onHookError) {
this._hookRunner.onError(this._onHookError);
}
}
return errors;
}
/**
* Subscribe to agent events.
* Session persistence is handled internally (saves messages on message_end).
* Multiple listeners can be added. Returns unsubscribe function for this listener.
*
* Note: Call initHooks() before subscribe() if you want to handle hook loading errors.
* Otherwise hooks are initialized automatically on first subscribe.
*/
subscribe(listener: AgentSessionEventListener): () => void {
this._eventListeners.push(listener);
// Initialize hooks if not done yet (fire and forget - errors go to callback)
if (!this._hooksInitialized && this._hookUIContext) {
this.initHooks().catch(() => {
// Errors are reported via onHookError callback
});
}
// Set up agent subscription if not already done
if (!this._unsubscribeAgent) {
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
@ -858,10 +952,14 @@ export class AgentSession {
/**
* Create a branch from a specific entry index.
* Emits branch event to hooks, which can control the branch behavior.
*
* @param entryIndex Index into session entries to branch from
* @returns The text of the selected user message (for editor pre-fill)
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - skipped: True if a hook requested to skip conversation restore
*/
branch(entryIndex: number): string {
async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> {
const entries = this.sessionManager.loadEntries();
const selectedEntry = entries[entryIndex];
@ -871,6 +969,21 @@ export class AgentSession {
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
// Emit branch event to hooks
let hookResult: BranchEventResult | undefined;
if (this._hookRunner?.hasHandlers("branch")) {
hookResult = await this._hookRunner.emit({
type: "branch",
targetTurnIndex: entryIndex,
entries,
});
}
// If hook says skip conversation restore, don't branch
if (hookResult?.skipConversationRestore) {
return { selectedText, skipped: true };
}
// Create branched session
const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
this.sessionManager.setSessionFile(newSessionFile);
@ -879,7 +992,7 @@ export class AgentSession {
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
this.agent.replaceMessages(loaded.messages);
return selectedText;
return { selectedText, skipped: false };
}
/**
@ -996,4 +1109,35 @@ export class AgentSession {
return text.trim() || null;
}
// =========================================================================
// Hook System
// =========================================================================
/**
* Check if hooks have handlers for a specific event type.
*/
hasHookHandlers(eventType: string): boolean {
return this._hookRunner?.hasHandlers(eventType) ?? false;
}
/**
* Get the hook runner (for advanced use cases).
*/
get hookRunner(): HookRunner | null {
return this._hookRunner;
}
/**
* Set hook UI context after construction.
* Useful when the UI context depends on components not available at construction time.
* Must be called before initHooks() or subscribe().
*/
setHookUIContext(context: HookUIContext, onError?: (error: HookError) => void): void {
if (this._hooksInitialized) {
throw new Error("Cannot set hook UI context after hooks have been initialized");
}
this._hookUIContext = context;
this._onHookError = onError;
}
}

View file

@ -0,0 +1,17 @@
export { type LoadedHook, type LoadHooksResult, loadHooks } from "./loader.js";
export { type HookErrorListener, HookRunner } from "./runner.js";
export type {
AgentEndEvent,
AgentStartEvent,
BranchEvent,
BranchEventResult,
ExecResult,
HookAPI,
HookError,
HookEvent,
HookEventContext,
HookFactory,
HookUIContext,
TurnEndEvent,
TurnStartEvent,
} from "./types.js";

View file

@ -0,0 +1,138 @@
/**
* Hook loader - loads TypeScript hook modules using jiti.
*/
import * as os from "node:os";
import * as path from "node:path";
import { createJiti } from "jiti";
import type { HookAPI, HookFactory } from "./types.js";
/**
* Generic handler function type.
*/
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Registered handlers for a loaded hook.
*/
export interface LoadedHook {
/** Original path from config */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>;
}
/**
* Result of loading hooks.
*/
export interface LoadHooksResult {
/** Successfully loaded hooks */
hooks: LoadedHook[];
/** Errors encountered during loading */
errors: Array<{ path: string; error: string }>;
}
/**
* Expand path with ~ support.
*/
function expandPath(p: string): string {
if (p.startsWith("~/")) {
return path.join(os.homedir(), p.slice(2));
}
if (p.startsWith("~")) {
return path.join(os.homedir(), p.slice(1));
}
return p;
}
/**
* Resolve hook path.
* - Absolute paths used as-is
* - Paths starting with ~ expanded to home directory
* - Relative paths resolved from cwd
*/
function resolveHookPath(hookPath: string, cwd: string): string {
const expanded = expandPath(hookPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
// Relative paths resolved from cwd
return path.resolve(cwd, expanded);
}
/**
* Create a HookAPI instance that collects handlers.
*/
function createHookAPI(handlers: Map<string, HandlerFn[]>): HookAPI {
return {
on(event: string, handler: HandlerFn): void {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
} as HookAPI;
}
/**
* Load a single hook module using jiti.
*/
async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> {
const resolvedPath = resolveHookPath(hookPath, cwd);
try {
// Create jiti instance for TypeScript/ESM loading
const jiti = createJiti(import.meta.url);
// Import the module
const module = await jiti.import(resolvedPath, { default: true });
const factory = module as HookFactory;
if (typeof factory !== "function") {
return { hook: null, error: "Hook must export a default function" };
}
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const api = createHookAPI(handlers);
// Call factory to register handlers
factory(api);
return {
hook: { path: hookPath, resolvedPath, handlers },
error: null,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { hook: null, error: `Failed to load hook: ${message}` };
}
}
/**
* Load all hooks from configuration.
* @param paths - Array of hook file paths
* @param cwd - Current working directory for resolving relative paths
*/
export async function loadHooks(paths: string[], cwd: string): Promise<LoadHooksResult> {
const hooks: LoadedHook[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (const hookPath of paths) {
const { hook, error } = await loadHook(hookPath, cwd);
if (error) {
errors.push({ path: hookPath, error });
continue;
}
if (hook) {
hooks.push(hook);
}
}
return { hooks, errors };
}

View file

@ -0,0 +1,157 @@
/**
* Hook runner - executes hooks and manages their lifecycle.
*/
import { spawn } from "node:child_process";
import type { LoadedHook } from "./loader.js";
import type { BranchEventResult, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext } from "./types.js";
/**
* Default timeout for hook execution (30 seconds).
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Listener for hook errors.
*/
export type HookErrorListener = (error: HookError) => void;
/**
* Execute a command and return stdout/stderr/code.
*/
async function exec(command: string, args: string[], cwd: string): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
proc.on("error", (_err) => {
resolve({ stdout, stderr, code: 1 });
});
});
}
/**
* Create a promise that rejects after a timeout.
*/
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let timeoutId: NodeJS.Timeout;
const promise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
});
return {
promise,
clear: () => clearTimeout(timeoutId),
};
}
/**
* HookRunner executes hooks and manages event emission.
*/
export class HookRunner {
private hooks: LoadedHook[];
private uiContext: HookUIContext;
private cwd: string;
private timeout: number;
private errorListeners: Set<HookErrorListener> = new Set();
constructor(hooks: LoadedHook[], uiContext: HookUIContext, cwd: string, timeout: number = DEFAULT_TIMEOUT) {
this.hooks = hooks;
this.uiContext = uiContext;
this.cwd = cwd;
this.timeout = timeout;
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function
*/
onError(listener: HookErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
/**
* Emit an error to all listeners.
*/
private emitError(error: HookError): void {
for (const listener of this.errorListeners) {
listener(error);
}
}
/**
* Check if any hooks have handlers for the given event type.
*/
hasHandlers(eventType: string): boolean {
for (const hook of this.hooks) {
const handlers = hook.handlers.get(eventType);
if (handlers && handlers.length > 0) {
return true;
}
}
return false;
}
/**
* Create the event context for handlers.
*/
private createContext(): HookEventContext {
return {
exec: (command: string, args: string[]) => exec(command, args, this.cwd),
ui: this.uiContext,
cwd: this.cwd,
};
}
/**
* Emit an event to all hooks.
* Returns the result from branch events (if any handler returns one).
*/
async emit(event: HookEvent): Promise<BranchEventResult | undefined> {
const ctx = this.createContext();
let result: BranchEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
if (!handlers || handlers.length === 0) continue;
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;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitError({
hookPath: hook.path,
event: event.type,
error: message,
});
}
}
}
return result;
}
}

View file

@ -0,0 +1,172 @@
/**
* Hook system types.
*
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
* and interact with the user via UI primitives.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { SessionEntry } from "../session-manager.js";
// ============================================================================
// Execution Context
// ============================================================================
/**
* Result of executing a command via ctx.exec()
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
}
/**
* UI context for hooks to request interactive UI from the harness.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
export interface HookUIContext {
/**
* Show a selector and return the user's choice.
* @param title - Title to display
* @param options - Array of string options
* @returns Selected option string, or null if cancelled
*/
select(title: string, options: string[]): Promise<string | null>;
/**
* Show a confirmation dialog.
* @returns true if confirmed, false if cancelled
*/
confirm(title: string, message: string): Promise<boolean>;
/**
* Show a text input dialog.
* @returns User input, or null if cancelled
*/
input(title: string, placeholder?: string): Promise<string | null>;
/**
* Show a notification to the user.
*/
notify(message: string, type?: "info" | "warning" | "error"): void;
}
/**
* Context passed to hook event handlers.
*/
export interface HookEventContext {
/** Execute a command and return stdout/stderr/code */
exec(command: string, args: string[]): Promise<ExecResult>;
/** UI methods for user interaction */
ui: HookUIContext;
/** Current working directory */
cwd: string;
}
// ============================================================================
// Events
// ============================================================================
/**
* Event data for agent_start event.
*/
export interface AgentStartEvent {
type: "agent_start";
}
/**
* Event data for agent_end event.
*/
export interface AgentEndEvent {
type: "agent_end";
messages: AppMessage[];
}
/**
* Event data for turn_start event.
*/
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/**
* Event data for turn_end event.
*/
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AppMessage;
toolResults: AppMessage[];
}
/**
* Event data for branch event.
*/
export interface BranchEvent {
type: "branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
/** Full session history */
entries: SessionEntry[];
}
/**
* Union of all hook event types.
*/
export type HookEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | BranchEvent;
// ============================================================================
// Event Results
// ============================================================================
/**
* Return type for branch event handlers.
* Allows hooks to control branch behavior.
*/
export interface BranchEventResult {
/** If true, skip restoring the conversation (only restore code) */
skipConversationRestore?: boolean;
}
// ============================================================================
// Hook API
// ============================================================================
/**
* Handler function type for each event.
*/
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
/**
* HookAPI passed to hook factory functions.
* Hooks use pi.on() to subscribe to events.
*/
export interface HookAPI {
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: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
}
/**
* Hook factory function type.
* Hooks export a default function that receives the HookAPI.
*/
export type HookFactory = (pi: HookAPI) => void;
// ============================================================================
// Errors
// ============================================================================
/**
* Error emitted when a hook fails.
*/
export interface HookError {
hookPath: string;
event: string;
error: string;
}

View file

@ -13,3 +13,13 @@ export {
type SessionStats,
} from "./agent-session.js";
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
export {
type HookAPI,
type HookError,
type HookEvent,
type HookEventContext,
type HookFactory,
HookRunner,
type HookUIContext,
loadHooks,
} from "./hooks/index.js";

View file

@ -19,6 +19,8 @@ export interface Settings {
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
}
export class SettingsManager {
@ -173,4 +175,22 @@ export class SettingsManager {
this.settings.collapseChangelog = collapse;
this.save();
}
getHookPaths(): string[] {
return this.settings.hooks ?? [];
}
setHookPaths(paths: string[]): void {
this.settings.hooks = paths;
this.save();
}
getHookTimeout(): number {
return this.settings.hookTimeout ?? 30000;
}
setHookTimeout(timeout: number): void {
this.settings.hookTimeout = timeout;
this.save();
}
}