Hook commands registered via pi.registerCommand() are now handled in AgentSession.prompt() alongside file-based slash commands. This: - Removes duplicate tryHandleHookCommand from interactive-mode and rpc-mode - All modes (interactive, RPC, print) share the same command handling logic - AgentSession._tryExecuteHookCommand() builds CommandContext using: - UI context from hookRunner (set by mode) - sessionManager, modelRegistry from AgentSession - sendMessage via sendHookMessage - exec via exported execCommand - Handler returning string uses it as prompt, undefined returns early Also: - Export execCommand from hooks/runner.ts - Add getUIContext() and getHasUI() to HookRunner - Make HookRunner.emitError() public for error reporting
378 lines
9.1 KiB
TypeScript
378 lines
9.1 KiB
TypeScript
/**
|
|
* Hook runner - executes hooks and manages their lifecycle.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
|
|
import type {
|
|
CustomMessageRenderer,
|
|
ExecOptions,
|
|
ExecResult,
|
|
HookError,
|
|
HookEvent,
|
|
HookEventContext,
|
|
HookUIContext,
|
|
RegisteredCommand,
|
|
SessionEvent,
|
|
SessionEventResult,
|
|
ToolCallEvent,
|
|
ToolCallEventResult,
|
|
ToolResultEventResult,
|
|
} 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.
|
|
* Supports cancellation via AbortSignal and timeout.
|
|
*/
|
|
export async function execCommand(
|
|
command: string,
|
|
args: string[],
|
|
cwd: string,
|
|
options?: ExecOptions,
|
|
): Promise<ExecResult> {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn(command, args, { cwd, shell: false });
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let killed = false;
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
|
|
const killProcess = () => {
|
|
if (!killed) {
|
|
killed = true;
|
|
proc.kill("SIGTERM");
|
|
// Force kill after 5 seconds if SIGTERM doesn't work
|
|
setTimeout(() => {
|
|
if (!proc.killed) {
|
|
proc.kill("SIGKILL");
|
|
}
|
|
}, 5000);
|
|
}
|
|
};
|
|
|
|
// Handle abort signal
|
|
if (options?.signal) {
|
|
if (options.signal.aborted) {
|
|
killProcess();
|
|
} else {
|
|
options.signal.addEventListener("abort", killProcess, { once: true });
|
|
}
|
|
}
|
|
|
|
// Handle timeout
|
|
if (options?.timeout && options.timeout > 0) {
|
|
timeoutId = setTimeout(() => {
|
|
killProcess();
|
|
}, options.timeout);
|
|
}
|
|
|
|
proc.stdout?.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr?.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
proc.on("close", (code) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (options?.signal) {
|
|
options.signal.removeEventListener("abort", killProcess);
|
|
}
|
|
resolve({ stdout, stderr, code: code ?? 0, killed });
|
|
});
|
|
|
|
proc.on("error", (_err) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (options?.signal) {
|
|
options.signal.removeEventListener("abort", killProcess);
|
|
}
|
|
resolve({ stdout, stderr, code: 1, killed });
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
};
|
|
}
|
|
|
|
/** 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[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
|
|
this.hooks = hooks;
|
|
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 UI context (set by mode).
|
|
*/
|
|
getUIContext(): HookUIContext | null {
|
|
return this.uiContext;
|
|
}
|
|
|
|
/**
|
|
* Get whether UI is available.
|
|
*/
|
|
getHasUI(): boolean {
|
|
return this.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 message handler for all hooks' pi.sendMessage().
|
|
* Call this when the mode initializes.
|
|
*/
|
|
setSendMessageHandler(handler: SendMessageHandler): void {
|
|
for (const hook of this.hooks) {
|
|
hook.setSendMessageHandler(handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the append entry handler for all hooks' pi.appendEntry().
|
|
* Call this when the mode initializes.
|
|
*/
|
|
setAppendEntryHandler(handler: AppendEntryHandler): void {
|
|
for (const hook of this.hooks) {
|
|
hook.setAppendEntryHandler(handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* Emit an error to all error listeners.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Get a custom message renderer for the given customType.
|
|
* Returns the first renderer found across all hooks, or undefined if none.
|
|
*/
|
|
getCustomMessageRenderer(customType: string): CustomMessageRenderer | undefined {
|
|
for (const hook of this.hooks) {
|
|
const renderer = hook.customMessageRenderers.get(customType);
|
|
if (renderer) {
|
|
return renderer;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Get all registered commands from all hooks.
|
|
*/
|
|
getRegisteredCommands(): RegisteredCommand[] {
|
|
const commands: RegisteredCommand[] = [];
|
|
for (const hook of this.hooks) {
|
|
for (const command of hook.commands.values()) {
|
|
commands.push(command);
|
|
}
|
|
}
|
|
return commands;
|
|
}
|
|
|
|
/**
|
|
* Get a registered command by name.
|
|
* Returns the first command found across all hooks, or undefined if none.
|
|
*/
|
|
getCommand(name: string): RegisteredCommand | undefined {
|
|
for (const hook of this.hooks) {
|
|
const command = hook.commands.get(name);
|
|
if (command) {
|
|
return command;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Create the event context for handlers.
|
|
*/
|
|
private createContext(): HookEventContext {
|
|
return {
|
|
exec: (command: string, args: string[], options?: ExecOptions) =>
|
|
execCommand(command, args, this.cwd, options),
|
|
ui: this.uiContext,
|
|
hasUI: this.hasUI,
|
|
cwd: this.cwd,
|
|
sessionFile: this.sessionFile,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Emit an event to all hooks.
|
|
* Returns the result from session/tool_result events (if any handler returns one).
|
|
*/
|
|
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
|
|
const ctx = this.createContext();
|
|
let result: SessionEventResult | ToolResultEventResult | 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 {
|
|
// No timeout for before_compact events (like tool_call, they may take a while)
|
|
const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact";
|
|
let handlerResult: unknown;
|
|
|
|
if (isBeforeCompact) {
|
|
handlerResult = await handler(event, ctx);
|
|
} else {
|
|
const timeout = createTimeout(this.timeout);
|
|
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
|
timeout.clear();
|
|
}
|
|
|
|
// For session events, capture the result (for before_* cancellation)
|
|
if (event.type === "session" && handlerResult) {
|
|
result = handlerResult as SessionEventResult;
|
|
// If cancelled, stop processing further hooks
|
|
if (result.cancel) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// 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({
|
|
hookPath: hook.path,
|
|
event: event.type,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|