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

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