/** * Plugin Hook Runner * * Provides utilities for executing plugin lifecycle hooks with proper * error handling, priority ordering, and async support. */ import { concatOptionalTextSegments } from "../shared/text/join-segments.js"; import type { PluginRegistry } from "./registry.js"; import type { PluginHookAfterCompactionEvent, PluginHookAfterToolCallEvent, PluginHookAgentContext, PluginHookAgentEndEvent, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeModelResolveEvent, PluginHookBeforeModelResolveResult, PluginHookBeforePromptBuildEvent, PluginHookBeforePromptBuildResult, PluginHookBeforeCompactionEvent, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, PluginHookBeforeResetEvent, PluginHookBeforeToolCallEvent, PluginHookBeforeToolCallResult, PluginHookGatewayContext, PluginHookGatewayStartEvent, PluginHookGatewayStopEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, PluginHookMessageSendingEvent, PluginHookMessageSendingResult, PluginHookMessageSentEvent, PluginHookName, PluginHookRegistration, PluginHookSessionContext, PluginHookSessionEndEvent, PluginHookSessionStartEvent, PluginHookSubagentContext, PluginHookSubagentDeliveryTargetEvent, PluginHookSubagentDeliveryTargetResult, PluginHookSubagentSpawningEvent, PluginHookSubagentSpawningResult, PluginHookSubagentEndedEvent, PluginHookSubagentSpawnedEvent, PluginHookToolContext, PluginHookToolResultPersistContext, PluginHookToolResultPersistEvent, PluginHookToolResultPersistResult, PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, } from "./types.js"; // Re-export types for consumers export type { PluginHookAgentContext, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeModelResolveEvent, PluginHookBeforeModelResolveResult, PluginHookBeforePromptBuildEvent, PluginHookBeforePromptBuildResult, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, PluginHookAgentEndEvent, PluginHookBeforeCompactionEvent, PluginHookBeforeResetEvent, PluginHookAfterCompactionEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, PluginHookMessageSendingEvent, PluginHookMessageSendingResult, PluginHookMessageSentEvent, PluginHookToolContext, PluginHookBeforeToolCallEvent, PluginHookBeforeToolCallResult, PluginHookAfterToolCallEvent, PluginHookToolResultPersistContext, PluginHookToolResultPersistEvent, PluginHookToolResultPersistResult, PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, PluginHookSessionContext, PluginHookSessionStartEvent, PluginHookSessionEndEvent, PluginHookSubagentContext, PluginHookSubagentDeliveryTargetEvent, PluginHookSubagentDeliveryTargetResult, PluginHookSubagentSpawningEvent, PluginHookSubagentSpawningResult, PluginHookSubagentSpawnedEvent, PluginHookSubagentEndedEvent, PluginHookGatewayContext, PluginHookGatewayStartEvent, PluginHookGatewayStopEvent, }; export type HookRunnerLogger = { debug?: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; }; export type HookRunnerOptions = { logger?: HookRunnerLogger; /** If true, errors in hooks will be caught and logged instead of thrown */ catchErrors?: boolean; }; /** * Get hooks for a specific hook name, sorted by priority (higher first). */ function getHooksForName( registry: PluginRegistry, hookName: K, ): PluginHookRegistration[] { return (registry.typedHooks as PluginHookRegistration[]) .filter((h) => h.hookName === hookName) .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } /** * Create a hook runner for a specific registry. */ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) { const logger = options.logger; const catchErrors = options.catchErrors ?? true; const mergeBeforeModelResolve = ( acc: PluginHookBeforeModelResolveResult | undefined, next: PluginHookBeforeModelResolveResult, ): PluginHookBeforeModelResolveResult => ({ // Keep the first defined override so higher-priority hooks win. modelOverride: acc?.modelOverride ?? next.modelOverride, providerOverride: acc?.providerOverride ?? next.providerOverride, }); const mergeBeforePromptBuild = ( acc: PluginHookBeforePromptBuildResult | undefined, next: PluginHookBeforePromptBuildResult, ): PluginHookBeforePromptBuildResult => ({ systemPrompt: next.systemPrompt ?? acc?.systemPrompt, prependContext: concatOptionalTextSegments({ left: acc?.prependContext, right: next.prependContext, }), prependSystemContext: concatOptionalTextSegments({ left: acc?.prependSystemContext, right: next.prependSystemContext, }), appendSystemContext: concatOptionalTextSegments({ left: acc?.appendSystemContext, right: next.appendSystemContext, }), }); const mergeSubagentSpawningResult = ( acc: PluginHookSubagentSpawningResult | undefined, next: PluginHookSubagentSpawningResult, ): PluginHookSubagentSpawningResult => { if (acc?.status === "error") { return acc; } if (next.status === "error") { return next; } return { status: "ok", threadBindingReady: Boolean(acc?.threadBindingReady || next.threadBindingReady), }; }; const mergeSubagentDeliveryTargetResult = ( acc: PluginHookSubagentDeliveryTargetResult | undefined, next: PluginHookSubagentDeliveryTargetResult, ): PluginHookSubagentDeliveryTargetResult => { if (acc?.origin) { return acc; } return next; }; const handleHookError = (params: { hookName: PluginHookName; pluginId: string; error: unknown; }): never | void => { const msg = `[hooks] ${params.hookName} handler from ${params.pluginId} failed: ${String( params.error, )}`; if (catchErrors) { logger?.error(msg); return; } throw new Error(msg, { cause: params.error }); }; /** * Run a hook that doesn't return a value (fire-and-forget style). * All handlers are executed in parallel for performance. */ async function runVoidHook( hookName: K, event: Parameters["handler"]>>[0], ctx: Parameters["handler"]>>[1], ): Promise { const hooks = getHooksForName(registry, hookName); if (hooks.length === 0) { return; } logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers)`); const promises = hooks.map(async (hook) => { try { await (hook.handler as (event: unknown, ctx: unknown) => Promise)(event, ctx); } catch (err) { handleHookError({ hookName, pluginId: hook.pluginId, error: err }); } }); await Promise.all(promises); } /** * Run a hook that can return a modifying result. * Handlers are executed sequentially in priority order, and results are merged. */ async function runModifyingHook( hookName: K, event: Parameters["handler"]>>[0], ctx: Parameters["handler"]>>[1], mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult, ): Promise { const hooks = getHooksForName(registry, hookName); if (hooks.length === 0) { return undefined; } logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`); let result: TResult | undefined; for (const hook of hooks) { try { const handlerResult = await ( hook.handler as (event: unknown, ctx: unknown) => Promise )(event, ctx); if (handlerResult !== undefined && handlerResult !== null) { if (mergeResults && result !== undefined) { result = mergeResults(result, handlerResult); } else { result = handlerResult; } } } catch (err) { handleHookError({ hookName, pluginId: hook.pluginId, error: err }); } } return result; } // ========================================================================= // Agent Hooks // ========================================================================= /** * Run before_model_resolve hook. * Allows plugins to override provider/model before model resolution. */ async function runBeforeModelResolve( event: PluginHookBeforeModelResolveEvent, ctx: PluginHookAgentContext, ): Promise { return runModifyingHook<"before_model_resolve", PluginHookBeforeModelResolveResult>( "before_model_resolve", event, ctx, mergeBeforeModelResolve, ); } /** * Run before_prompt_build hook. * Allows plugins to inject context and system prompt before prompt submission. */ async function runBeforePromptBuild( event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext, ): Promise { return runModifyingHook<"before_prompt_build", PluginHookBeforePromptBuildResult>( "before_prompt_build", event, ctx, mergeBeforePromptBuild, ); } /** * Run before_agent_start hook. * Legacy compatibility hook that combines model resolve + prompt build phases. */ async function runBeforeAgentStart( event: PluginHookBeforeAgentStartEvent, ctx: PluginHookAgentContext, ): Promise { return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>( "before_agent_start", event, ctx, (acc, next) => ({ ...mergeBeforePromptBuild(acc, next), ...mergeBeforeModelResolve(acc, next), }), ); } /** * Run agent_end hook. * Allows plugins to analyze completed conversations. * Runs in parallel (fire-and-forget). */ async function runAgentEnd( event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext, ): Promise { return runVoidHook("agent_end", event, ctx); } /** * Run llm_input hook. * Allows plugins to observe the exact input payload sent to the LLM. * Runs in parallel (fire-and-forget). */ async function runLlmInput(event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) { return runVoidHook("llm_input", event, ctx); } /** * Run llm_output hook. * Allows plugins to observe the exact output payload returned by the LLM. * Runs in parallel (fire-and-forget). */ async function runLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext) { return runVoidHook("llm_output", event, ctx); } /** * Run before_compaction hook. */ async function runBeforeCompaction( event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext, ): Promise { return runVoidHook("before_compaction", event, ctx); } /** * Run after_compaction hook. */ async function runAfterCompaction( event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext, ): Promise { return runVoidHook("after_compaction", event, ctx); } /** * Run before_reset hook. * Fired when /new or /reset clears a session, before messages are lost. * Runs in parallel (fire-and-forget). */ async function runBeforeReset( event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext, ): Promise { return runVoidHook("before_reset", event, ctx); } // ========================================================================= // Message Hooks // ========================================================================= /** * Run message_received hook. * Runs in parallel (fire-and-forget). */ async function runMessageReceived( event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext, ): Promise { return runVoidHook("message_received", event, ctx); } /** * Run message_sending hook. * Allows plugins to modify or cancel outgoing messages. * Runs sequentially. */ async function runMessageSending( event: PluginHookMessageSendingEvent, ctx: PluginHookMessageContext, ): Promise { return runModifyingHook<"message_sending", PluginHookMessageSendingResult>( "message_sending", event, ctx, (acc, next) => ({ content: next.content ?? acc?.content, cancel: next.cancel ?? acc?.cancel, }), ); } /** * Run message_sent hook. * Runs in parallel (fire-and-forget). */ async function runMessageSent( event: PluginHookMessageSentEvent, ctx: PluginHookMessageContext, ): Promise { return runVoidHook("message_sent", event, ctx); } // ========================================================================= // Tool Hooks // ========================================================================= /** * Run before_tool_call hook. * Allows plugins to modify or block tool calls. * Runs sequentially. */ async function runBeforeToolCall( event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext, ): Promise { return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>( "before_tool_call", event, ctx, (acc, next) => ({ params: next.params ?? acc?.params, block: next.block ?? acc?.block, blockReason: next.blockReason ?? acc?.blockReason, }), ); } /** * Run after_tool_call hook. * Runs in parallel (fire-and-forget). */ async function runAfterToolCall( event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext, ): Promise { return runVoidHook("after_tool_call", event, ctx); } /** * Run tool_result_persist hook. * * This hook is intentionally synchronous: it runs in hot paths where session * transcripts are appended synchronously. * * Handlers are executed sequentially in priority order (higher first). Each * handler may return `{ message }` to replace the message passed to the next * handler. */ function runToolResultPersist( event: PluginHookToolResultPersistEvent, ctx: PluginHookToolResultPersistContext, ): PluginHookToolResultPersistResult | undefined { const hooks = getHooksForName(registry, "tool_result_persist"); if (hooks.length === 0) { return undefined; } let current = event.message; for (const hook of hooks) { try { // oxlint-disable-next-line typescript/no-explicit-any const out = (hook.handler as any)({ ...event, message: current }, ctx) as | PluginHookToolResultPersistResult | void | Promise; // Guard against accidental async handlers (this hook is sync-only). // oxlint-disable-next-line typescript/no-explicit-any if (out && typeof (out as any).then === "function") { const msg = `[hooks] tool_result_persist handler from ${hook.pluginId} returned a Promise; ` + `this hook is synchronous and the result was ignored.`; if (catchErrors) { logger?.warn?.(msg); continue; } throw new Error(msg); } const next = (out as PluginHookToolResultPersistResult | undefined)?.message; if (next) { current = next; } } catch (err) { const msg = `[hooks] tool_result_persist handler from ${hook.pluginId} failed: ${String(err)}`; if (catchErrors) { logger?.error(msg); } else { throw new Error(msg, { cause: err }); } } } return { message: current }; } // ========================================================================= // Message Write Hooks // ========================================================================= /** * Run before_message_write hook. * * This hook is intentionally synchronous: it runs on the hot path where * session transcripts are appended synchronously. * * Handlers are executed sequentially in priority order (higher first). * If any handler returns { block: true }, the message is NOT written * to the session JSONL and we return immediately. * If a handler returns { message }, the modified message replaces the * original for subsequent handlers and the final write. */ function runBeforeMessageWrite( event: PluginHookBeforeMessageWriteEvent, ctx: { agentId?: string; sessionKey?: string }, ): PluginHookBeforeMessageWriteResult | undefined { const hooks = getHooksForName(registry, "before_message_write"); if (hooks.length === 0) { return undefined; } let current = event.message; for (const hook of hooks) { try { // oxlint-disable-next-line typescript/no-explicit-any const out = (hook.handler as any)({ ...event, message: current }, ctx) as | PluginHookBeforeMessageWriteResult | void | Promise; // Guard against accidental async handlers (this hook is sync-only). // oxlint-disable-next-line typescript/no-explicit-any if (out && typeof (out as any).then === "function") { const msg = `[hooks] before_message_write handler from ${hook.pluginId} returned a Promise; ` + `this hook is synchronous and the result was ignored.`; if (catchErrors) { logger?.warn?.(msg); continue; } throw new Error(msg); } const result = out as PluginHookBeforeMessageWriteResult | undefined; // If any handler blocks, return immediately. if (result?.block) { return { block: true }; } // If handler provided a modified message, use it for subsequent handlers. if (result?.message) { current = result.message; } } catch (err) { const msg = `[hooks] before_message_write handler from ${hook.pluginId} failed: ${String(err)}`; if (catchErrors) { logger?.error(msg); } else { throw new Error(msg, { cause: err }); } } } // If message was modified by any handler, return it. if (current !== event.message) { return { message: current }; } return undefined; } // ========================================================================= // Session Hooks // ========================================================================= /** * Run session_start hook. * Runs in parallel (fire-and-forget). */ async function runSessionStart( event: PluginHookSessionStartEvent, ctx: PluginHookSessionContext, ): Promise { return runVoidHook("session_start", event, ctx); } /** * Run session_end hook. * Runs in parallel (fire-and-forget). */ async function runSessionEnd( event: PluginHookSessionEndEvent, ctx: PluginHookSessionContext, ): Promise { return runVoidHook("session_end", event, ctx); } /** * Run subagent_spawning hook. * Runs sequentially so channel plugins can deterministically provision session bindings. */ async function runSubagentSpawning( event: PluginHookSubagentSpawningEvent, ctx: PluginHookSubagentContext, ): Promise { return runModifyingHook<"subagent_spawning", PluginHookSubagentSpawningResult>( "subagent_spawning", event, ctx, mergeSubagentSpawningResult, ); } /** * Run subagent_delivery_target hook. * Runs sequentially so channel plugins can deterministically resolve routing. */ async function runSubagentDeliveryTarget( event: PluginHookSubagentDeliveryTargetEvent, ctx: PluginHookSubagentContext, ): Promise { return runModifyingHook<"subagent_delivery_target", PluginHookSubagentDeliveryTargetResult>( "subagent_delivery_target", event, ctx, mergeSubagentDeliveryTargetResult, ); } /** * Run subagent_spawned hook. * Runs in parallel (fire-and-forget). */ async function runSubagentSpawned( event: PluginHookSubagentSpawnedEvent, ctx: PluginHookSubagentContext, ): Promise { return runVoidHook("subagent_spawned", event, ctx); } /** * Run subagent_ended hook. * Runs in parallel (fire-and-forget). */ async function runSubagentEnded( event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext, ): Promise { return runVoidHook("subagent_ended", event, ctx); } // ========================================================================= // Gateway Hooks // ========================================================================= /** * Run gateway_start hook. * Runs in parallel (fire-and-forget). */ async function runGatewayStart( event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext, ): Promise { return runVoidHook("gateway_start", event, ctx); } /** * Run gateway_stop hook. * Runs in parallel (fire-and-forget). */ async function runGatewayStop( event: PluginHookGatewayStopEvent, ctx: PluginHookGatewayContext, ): Promise { return runVoidHook("gateway_stop", event, ctx); } // ========================================================================= // Utility // ========================================================================= /** * Check if any hooks are registered for a given hook name. */ function hasHooks(hookName: PluginHookName): boolean { return registry.typedHooks.some((h) => h.hookName === hookName); } /** * Get count of registered hooks for a given hook name. */ function getHookCount(hookName: PluginHookName): number { return registry.typedHooks.filter((h) => h.hookName === hookName).length; } return { // Agent hooks runBeforeModelResolve, runBeforePromptBuild, runBeforeAgentStart, runLlmInput, runLlmOutput, runAgentEnd, runBeforeCompaction, runAfterCompaction, runBeforeReset, // Message hooks runMessageReceived, runMessageSending, runMessageSent, // Tool hooks runBeforeToolCall, runAfterToolCall, runToolResultPersist, // Message write hooks runBeforeMessageWrite, // Session hooks runSessionStart, runSessionEnd, runSubagentSpawning, runSubagentDeliveryTarget, runSubagentSpawned, runSubagentEnded, // Gateway hooks runGatewayStart, runGatewayStop, // Utility hasHooks, getHookCount, }; } export type HookRunner = ReturnType;