import { pathToFileURL } from 'node:url' import type { ConfigRegistry } from '../config/registry.js' import { mcpToolToToolDefinition } from '../connector/mcp/adapter.js' import { MCPClient } from '../connector/mcp/client.js' import { MCPToolDiscovery } from '../connector/mcp/discovery.js' import type { MCPToolDiscoveryOptions } from '../connector/mcp/discovery.js' import type { MCPToolPolicy } from '../connector/mcp/policy.js' import { mcpPromptToToolDefinition } from '../connector/mcp/prompt-adapter.js' import { MCPReconnectOptionsSchema, MCPReconnectSupervisor } from '../connector/mcp/reconnect.js' import { DEFAULT_HOOK_PRIORITY, HOOK_TIMEOUT_MS, PLUGIN_NAMESPACE_SEPARATOR, } from '../constants/plugin/index.js' import { GENAI } from '../constants/telemetry/index.js' import type { PluginRegistry } from '../registry/plugin/index.js' import { loadSkill } from '../skills/loader.js' import type { SkillRegistry } from '../skills/registry.js' import { resolveWithinReal } from '../tools/paths.js' import type { PluginId } from '../types/ids/index.js' import type { PluginDefinition, PluginEventListener, PluginHookContext, PluginHookDefinition, PluginHookEvent, PluginHookResult, PluginLifecycleEvent, PluginMCPServerConfig, PluginScope, } from '../types/plugin/index.js' import { assertPluginHookEvent } from '../types/plugin/index.js' import type { SessionEvent } from '../types/session/index.js' import type { ToolDefinition, ToolRegistryContract } from '../types/tool/index.js' import { toErrorMessage } from '../utils/error.js' import { generatePluginId } from '../utils/id.js' import { SCOPE_ATTRIBUTE } from '../utils/log/types.js' import type { Logger } from '../utils/logger.js' import { assertEnableable, loadPluginManifest } from './loader.js' interface PluginContributionRecord { toolNames: string[] mcpClients: MCPClient[] /** Namespaced skill names, so rollback and disable can take them back. */ skillNames: string[] /** * One per client, held so teardown can stop them. * * A supervisor still attached when `disconnect()` runs reads the teardown * as a fault and reconnects what was just closed — the lifecycle event * cannot tell a deliberate disconnect from a dropped transport. Keeping * them here is what makes the required stop-then-disconnect ordering * possible at all. */ mcpSupervisors: MCPReconnectSupervisor[] } interface PluginAdmission { readonly id: PluginId readonly manifest: PluginDefinition['manifest'] readonly scope: PluginScope readonly rootDir: string readonly installedAt: number } function immutableManifest(manifest: PluginDefinition['manifest']): PluginDefinition['manifest'] { const mcpServers = manifest.mcpServers?.map((server) => Object.freeze({ name: server.name, command: server.command, ...(server.args ? { args: Object.freeze([...server.args]) } : {}), ...(server.env ? { env: Object.freeze({ ...server.env }) } : {}), }), ) return Object.freeze({ name: manifest.name, version: manifest.version, description: manifest.description, ...(manifest.author !== undefined ? { author: manifest.author } : {}), ...(manifest.tools ? { tools: Object.freeze([...manifest.tools]) } : {}), ...(manifest.skills ? { skills: Object.freeze([...manifest.skills]) } : {}), ...(manifest.hooks ? { hooks: Object.freeze([...manifest.hooks]) } : {}), ...(mcpServers ? { mcpServers: Object.freeze(mcpServers) } : {}), ...(manifest.connectors ? { connectors: Object.freeze([...manifest.connectors]) } : {}), ...(manifest.personas ? { personas: Object.freeze([...manifest.personas]) } : {}), }) } export interface PluginLifecycleManagerConfig { pluginRegistry: PluginRegistry toolRegistry: ToolRegistryContract /** * Filesystem authorities for each plugin scope. * * Required because `scope` is not an authority by itself. The manager * canonicalizes a plugin against the matching root before reading its * manifest and repeats that admission for legacy registry records before * enabling them. A project manager normally uses the trusted working * directory; a user manager normally uses the user's home directory. */ scopeRoots: Readonly> /** * Where a plugin's declared skills land. * * Optional, and its absence is enforced rather than tolerated: a * manifest that declares skills is REFUSED when this is missing, the * same way it was refused before the manifest path existed. Accepting it * and dropping the skills would produce a plugin reporting `enabled` * that contributes nothing its author declared. */ skillRegistry?: SkillRegistry log: Logger hookTimeoutMs?: number /** * Where each MCP server's reconnect policy is registered, so an operator * can retune it while a turn is live. * * Optional, and its absence is not a degraded mode: without one the * supervisor uses its own defaults, which is what it did before this * existed. What a registry buys is the ability to change them without * restarting the process — see `config/registry.ts`. */ configRegistry?: ConfigRegistry /** * What each MCP server a plugin brings is allowed to contribute, keyed by * server name. `'*'` covers every server not named explicitly. * * Absent admits everything, which is what this path did unconditionally * until now. `MCPToolDiscovery` has held this boundary — and the drift * detection below — since it was written, and nothing outside its own * tests ever constructed one: `attachMCPServer` called `listTools()` * directly and registered whatever came back. So the least-privilege * check existed, was tested, was exported, and was not on the path any * real MCP server takes. */ mcpToolPolicies?: Readonly> /** * Called when a server's admitted tool set differs from the last time it * was discovered. * * Reported rather than blocked, for the reason `MCPToolDiscovery` * already gives: a development server legitimately changes between sessions, * while a production one changing mid-session is the rug pull — advertise * something benign at approval time, swap it afterwards. Only the host * knows which it is looking at. */ onMCPToolDrift?: MCPToolDiscoveryOptions['onDrift'] } export class PluginLifecycleManager { private pluginRegistry: PluginRegistry private toolRegistry: ToolRegistryContract private listeners: PluginEventListener[] = [] private hookHandlers: Map< PluginHookEvent, Array<{ pluginId: PluginId handler: PluginHookDefinition['handler'] priority: number seq: number }> > = new Map() private pluginContributions: Map = new Map() /** Immutable executable admissions owned by this manager, never by the mutable registry. */ private pluginAdmissions: Map = new Map() private hookTimeoutMs: number private readonly configRegistry: ConfigRegistry | undefined private readonly scopeRoots: Readonly> private log: Logger /** * The admission boundary for everything a plugin's MCP servers advertise. * * One instance for the whole manager rather than one per plugin, and that * is the part that matters: the instance is what remembers each server's * previously admitted tool set, so a server that changes between one * plugin being disabled and the next being enabled is still noticed. A * per-plugin instance would forget on every teardown, which is precisely * the window a rug pull uses. * * Its client list is deliberately left empty — `discoverFrom` takes the * client directly, so registering it here would be bookkeeping nothing * reads. */ private mcpDiscovery: MCPToolDiscovery private readonly skillRegistry: SkillRegistry | undefined constructor(config: PluginLifecycleManagerConfig) { this.pluginRegistry = config.pluginRegistry this.toolRegistry = config.toolRegistry this.skillRegistry = config.skillRegistry this.scopeRoots = Object.freeze({ ...config.scopeRoots }) this.hookTimeoutMs = config.hookTimeoutMs ?? HOOK_TIMEOUT_MS this.configRegistry = config.configRegistry this.log = config.log.child({ [SCOPE_ATTRIBUTE]: 'plugin/lifecycle' }) this.mcpDiscovery = new MCPToolDiscovery([], { ...(config.mcpToolPolicies ? { policies: config.mcpToolPolicies } : {}), ...(config.onMCPToolDrift ? { onDrift: config.onMCPToolDrift } : {}), logger: this.log, }) } /** * Attach a hook without installing a plugin from disk. * * Registration was reachable only through `enable()`, which loads a * manifest and imports modules by path — so a host that wanted one * in-process guard had to lay out a plugin directory to get it. That * also left this class's own tests reaching into the private map, * which is how they came to construct entries the real path would * never produce. * * Hooks are held in priority order (lower first), ties keeping * registration order. */ registerHook(pluginId: PluginId, hook: PluginHookDefinition): void { // Refused here rather than left to never fire: a hook module still // naming `run_start` would otherwise attach to an event nothing emits, // and the operator would learn of the rename only by its silence. assertPluginHookEvent(hook.event) const handlers = this.hookHandlers.get(hook.event) ?? [] handlers.push({ pluginId, handler: hook.handler, priority: hook.priority ?? DEFAULT_HOOK_PRIORITY, // Registration index, so the sort has something stable to fall // back on when priorities are equal. seq: handlers.length, }) // Sorted on insert rather than on dispatch: a chain runs on every // tool call, and the order only changes when a plugin comes or goes. handlers.sort((a, b) => a.priority - b.priority || a.seq - b.seq) this.hookHandlers.set(hook.event, handlers) } on(listener: PluginEventListener): void { this.listeners.push(listener) } off(listener: PluginEventListener): void { const index = this.listeners.indexOf(listener) if (index >= 0) this.listeners.splice(index, 1) } async install(pluginDir: string, scope: PluginScope): Promise { // Admission and enable read the same constructor-owned capability. The // loader defaults this to false for direct callers; a manager that really // owns the registry is the authority that can opt the manifest in. const rootDir = await resolveWithinReal(this.scopeRoots[scope], pluginDir) const manifest = immutableManifest( await loadPluginManifest(rootDir, { skillsSupported: Boolean(this.skillRegistry), }), ) const existing = this.pluginRegistry.findByName(manifest.name) if (existing) { throw new Error(`Plugin "${manifest.name}" is already installed (id: ${existing.id})`) } const pluginId = generatePluginId() const admission: PluginAdmission = Object.freeze({ id: pluginId, manifest, scope, rootDir, installedAt: Date.now(), }) const definition = this.definitionFrom(admission, 'installed') this.pluginRegistry.register(definition) this.pluginAdmissions.set(pluginId, admission) this.emit({ type: 'plugin_installed', pluginId, name: manifest.name, scope, }) this.log.info('Plugin installed', { 'namzu.plugin.name': manifest.name, 'namzu.plugin.id': pluginId, 'namzu.plugin.scope': scope, 'namzu.plugin.version': manifest.version, }) return definition } private definitionFrom( admission: PluginAdmission, status: PluginDefinition['status'], enabledAt?: number, ): PluginDefinition { return Object.freeze({ ...admission, status, ...(enabledAt !== undefined ? { enabledAt } : {}), }) } private async readLegacyAdmission(plugin: PluginDefinition): Promise { const rootDir = await resolveWithinReal(this.scopeRoots[plugin.scope], plugin.rootDir) const manifest = immutableManifest( await loadPluginManifest(rootDir, { skillsSupported: Boolean(this.skillRegistry), }), ) if (manifest.name !== plugin.manifest.name) { throw new Error( `Plugin registry record "${plugin.id}" names "${plugin.manifest.name}", but its admitted manifest names "${manifest.name}". Reinstall the plugin from its declared scope root.`, ) } const duplicate = this.pluginRegistry .getAll() .find((candidate) => candidate.id !== plugin.id && candidate.manifest.name === manifest.name) if (duplicate) { throw new Error( `Plugin "${manifest.name}" is already installed (id: ${duplicate.id}); legacy record ${plugin.id} cannot be re-admitted.`, ) } return Object.freeze({ id: plugin.id, manifest, scope: plugin.scope, rootDir, installedAt: plugin.installedAt, }) } async enable(pluginId: PluginId): Promise { const plugin = this.pluginRegistry.getOrThrow(pluginId) let admission = this.pluginAdmissions.get(pluginId) // The registry is a public projection and can be overwritten by a host. // Contribution ownership is the executable lifecycle truth: trusting a // forged `disabled` status here would register every hook/tool/MCP client // twice and lose teardown ownership of the first set. if (this.pluginContributions.has(pluginId)) { throw new Error( `Cannot enable plugin "${admission?.manifest.name ?? plugin.manifest.name}": status is "enabled" (expected "installed" or "disabled")`, ) } if (!admission) { if (plugin.status !== 'installed' && plugin.status !== 'disabled') { throw new Error( `Cannot enable plugin "${plugin.manifest.name}": status is "${plugin.status}" (expected "installed" or "disabled")`, ) } try { admission = await this.readLegacyAdmission(plugin) this.pluginAdmissions.set(pluginId, admission) } catch (error) { this.pluginRegistry.register({ ...plugin, status: 'error', error: toErrorMessage(error), }) throw error } } const { manifest } = admission // The same refusal the loader applies at install, kept here as the // backstop for a plugin that reached this point another way — a // record written by an older build, or a host constructing one // directly. Reaching it means the install-time gate was bypassed, // so the plugin transitions to `error` rather than staying // `installed`: a status that says the plugin is fine while it can // never enable is how the next reader gets misled. try { assertEnableable(manifest, { skillsSupported: Boolean(this.skillRegistry) }) } catch (err) { this.pluginRegistry.register({ ...this.definitionFrom(admission, 'error'), error: toErrorMessage(err), }) throw err } const contributions: PluginContributionRecord = { toolNames: [], mcpClients: [], skillNames: [], mcpSupervisors: [], } try { // Load tools if (manifest.tools && manifest.tools.length > 0) { for (const toolPath of manifest.tools) { const absolutePath = await resolveWithinReal(admission.rootDir, toolPath) const fileUrl = pathToFileURL(absolutePath).href const mod = (await import(fileUrl)) as { tools?: ToolDefinition[] } if (!mod.tools || !Array.isArray(mod.tools)) { throw new Error( `Plugin "${manifest.name}": tool module "${toolPath}" must export a "tools" array`, ) } for (const tool of mod.tools) { const namespacedName = manifest.name + PLUGIN_NAMESPACE_SEPARATOR + tool.name const namespacedTool: ToolDefinition = { ...tool, name: namespacedName } this.toolRegistry.register(namespacedTool, 'deferred') contributions.toolNames.push(namespacedName) } } } // Load skills. Namespaced like tools, and for the same reason: two // plugins shipping `reconcile` would otherwise overwrite each // other in a Map keyed by the frontmatter name, and the loser // would vanish with nothing reporting it. if (manifest.skills && manifest.skills.length > 0) { const skillRegistry = this.skillRegistry if (!skillRegistry) { // Unreachable via `enable` — `assertEnableable` above refuses // first — and kept because this method is also the one a // future caller reaches directly. A silent skip here would // be the exact failure that check exists to prevent. throw new Error( `Plugin "${manifest.name}" declares skills but no SkillRegistry is configured.`, ) } for (const skillPath of manifest.skills) { const absolutePath = await resolveWithinReal(admission.rootDir, skillPath) const { skill } = await loadSkill(absolutePath, 'metadata', this.log) const namespacedName = manifest.name + PLUGIN_NAMESPACE_SEPARATOR + skill.metadata.name skillRegistry.add(namespacedName, { ...skill, metadata: { ...skill.metadata, name: namespacedName }, }) contributions.skillNames.push(namespacedName) } } // Load hooks if (manifest.hooks && manifest.hooks.length > 0) { for (const hookPath of manifest.hooks) { const absolutePath = await resolveWithinReal(admission.rootDir, hookPath) const fileUrl = pathToFileURL(absolutePath).href const mod = (await import(fileUrl)) as { hooks?: PluginHookDefinition[] } if (!mod.hooks || !Array.isArray(mod.hooks)) { throw new Error( `Plugin "${manifest.name}": hook module "${hookPath}" must export a "hooks" array`, ) } for (const hook of mod.hooks) { this.registerHook(pluginId, hook) } } } // Start MCP servers and adapt their tools if (manifest.mcpServers && manifest.mcpServers.length > 0) { for (const serverConfig of manifest.mcpServers) { await this.attachMCPServer(manifest.name, serverConfig, contributions) } } } catch (err) { await this.rollbackContributions(pluginId, contributions) throw err } this.pluginContributions.set(pluginId, contributions) const enabled = this.definitionFrom(admission, 'enabled', Date.now()) this.pluginRegistry.register(enabled) this.emit({ type: 'plugin_enabled', pluginId, name: manifest.name, }) this.log.info('Plugin enabled', { // Every key namespaced, not just the newest one. Adding a bare // `skillCount` beside bare neighbours would have moved the // log-standard ratchet the wrong way for the sake of matching // prose that is itself the debt. 'namzu.plugin.id': pluginId, 'namzu.plugin.name': manifest.name, 'namzu.plugin.tool_count': contributions.toolNames.length, 'namzu.plugin.skill_count': contributions.skillNames.length, 'namzu.plugin.mcp_server_count': contributions.mcpClients.length, }) } private async attachMCPServer( pluginName: string, config: PluginMCPServerConfig, contributions: PluginContributionRecord, ): Promise { const client = new MCPClient({ serverName: config.name, transport: { type: 'stdio', command: config.command, args: config.args ? [...config.args] : undefined, env: config.env ? { ...config.env } : undefined, }, logger: this.log, }) await client.connect() contributions.mcpClients.push(client) // Watched from here on. `connect()` above is the only attempt anything // made: `transport.onClose` marked the client disconnected and rejected // its pending calls, and nothing scheduled another try — so one blip // took this plugin's tools out for the life of the process while the // plugin went on reporting as enabled. // The policy is read from the registry on EVERY attempt, not captured // here. That is what makes the seam live: raising `maxAttempts` during // an outage takes effect on the retry that is already running, which is // the moment an operator actually reaches for it. // // Namespaced per server rather than globally, because two plugins' // servers fail differently — one behind a flaky proxy wants patience, // one behind a crash loop wants to give up and say so. const policyScope = this.configRegistry?.register( `mcp.${config.name}`, MCPReconnectOptionsSchema, ) const supervisor = new MCPReconnectSupervisor( client, policyScope ? () => policyScope.get() : {}, ) supervisor.start() contributions.mcpSupervisors.push(supervisor) // Through the boundary, not around it. This used to call // `client.listTools()` and register everything the server answered // with, so the remote side decided what entered the agent's registry // — least privilege inverted. `discoverFrom` applies the per-server // allow/deny policy and reports a tool set that changed since the // last discovery. const discovered = await this.mcpDiscovery.discoverFrom(client) const mcpTools = discovered.map((d) => d.tool) for (const mcpTool of mcpTools) { const baseDef = mcpToolToToolDefinition(mcpTool, client, config.name) // Double-underscore between serverName and toolName so that e.g. // (server="fs", tool="read_file") and (server="fs_read", tool="file") // produce distinct final names. const namespacedName = `${pluginName}${PLUGIN_NAMESPACE_SEPARATOR}mcp__${config.name}__${mcpTool.name}` const namespacedTool: ToolDefinition = { ...baseDef, name: namespacedName } this.toolRegistry.register(namespacedTool, 'deferred') contributions.toolNames.push(namespacedName) } // Prompts, through the same gate. A server publishing one is the same // trust question as a server publishing a tool. const prompts = await this.mcpDiscovery.discoverPromptsFrom(client) for (const prompt of prompts) { const baseDef = mcpPromptToToolDefinition(prompt, client, config.name) const namespacedName = `${pluginName}${PLUGIN_NAMESPACE_SEPARATOR}${baseDef.name}` this.toolRegistry.register({ ...baseDef, name: namespacedName }, 'deferred') contributions.toolNames.push(namespacedName) } } private async rollbackContributions( pluginId: PluginId, contributions: PluginContributionRecord, ): Promise { for (const name of contributions.toolNames) { try { this.toolRegistry.unregister(name) } catch (unregErr) { this.log.warn('Rollback: tool unregister failed', { [GENAI.TOOL_NAME]: name, 'exception.message': toErrorMessage(unregErr), }) } } for (const name of contributions.skillNames) { // No try/catch: `unregister` is a Map delete and cannot throw. // Wrapping it would suggest a failure mode that does not exist. this.skillRegistry?.unregister(name) } // Stop supervising BEFORE disconnecting. The lifecycle event a // deliberate disconnect emits is the same one a dropped transport // emits, so a still-attached supervisor would reconnect what this // rollback just closed. for (const supervisor of contributions.mcpSupervisors) supervisor.stop() for (const client of contributions.mcpClients) { try { await client.disconnect() } catch (discErr) { this.log.warn('Rollback: MCP disconnect failed', { 'namzu.mcp.client_id': client.id, 'exception.message': toErrorMessage(discErr), }) } } for (const [event, handlers] of this.hookHandlers) { const filtered = handlers.filter((h) => h.pluginId !== pluginId) if (filtered.length === 0) { this.hookHandlers.delete(event) } else { this.hookHandlers.set(event, filtered) } } } async disable(pluginId: PluginId): Promise { const plugin = this.pluginRegistry.getOrThrow(pluginId) const admission = this.pluginAdmissions.get(pluginId) const contributions = this.pluginContributions.get(pluginId) if (!contributions) { throw new Error( `Cannot disable plugin "${admission?.manifest.name ?? plugin.manifest.name}": status is not "enabled"`, ) } // Stop supervising first, for the same reason as the rollback path: the // disconnect below emits the event the supervisor treats as a fault. for (const supervisor of contributions.mcpSupervisors) supervisor.stop() // Disconnect MCP clients first so no new tool calls can reach them mid-teardown. for (const client of contributions.mcpClients) { try { await client.disconnect() } catch (err) { this.log.warn('MCP client disconnect failed during disable', { 'namzu.mcp.client_id': client.id, 'exception.message': toErrorMessage(err), }) } } // Unregister contributed tools (plugin tools + MCP-adapted tools) for (const name of contributions.toolNames) { this.toolRegistry.unregister(name) } // And its skills. A disabled plugin whose skills stayed registered // would keep offering the model instructions from something the // runtime has switched off, which is worse than a stale tool: a tool // call would at least fail, and a skill is followed silently. for (const name of contributions.skillNames) { this.skillRegistry?.unregister(name) } // Remove hook handlers for this plugin for (const [event, handlers] of this.hookHandlers) { const filtered = handlers.filter((h) => h.pluginId !== pluginId) if (filtered.length === 0) { this.hookHandlers.delete(event) } else { this.hookHandlers.set(event, filtered) } } this.pluginContributions.delete(pluginId) // Update status to disabled const disabled: PluginDefinition = admission ? this.definitionFrom(admission, 'disabled') : { ...plugin, status: 'disabled', enabledAt: undefined } this.pluginRegistry.register(disabled) this.emit({ type: 'plugin_disabled', pluginId, name: disabled.manifest.name, }) this.log.info('Plugin disabled', { 'namzu.plugin.name': disabled.manifest.name, 'namzu.plugin.id': pluginId, }) } async uninstall(pluginId: PluginId): Promise { const plugin = this.pluginRegistry.getOrThrow(pluginId) const admission = this.pluginAdmissions.get(pluginId) if (this.pluginContributions.has(pluginId)) { await this.disable(pluginId) } this.pluginRegistry.unregister(pluginId) this.pluginAdmissions.delete(pluginId) this.emit({ type: 'plugin_uninstalled', pluginId, name: admission?.manifest.name ?? plugin.manifest.name, }) this.log.info('Plugin uninstalled', { 'namzu.plugin.name': admission?.manifest.name ?? plugin.manifest.name, 'namzu.plugin.id': pluginId, }) } async executeHooks( event: PluginHookEvent, context: Omit, emitSessionEvent?: (event: SessionEvent) => Promise, ): Promise { const callerSignal = context.signal callerSignal?.throwIfAborted() const handlers = this.hookHandlers.get(event) if (!handlers || handlers.length === 0) { return [] } const results: PluginHookResult[] = [] // Interrupt hooks are cleanup/notification observers. One extension's // skip, error, retry, or timeout must not suppress the extensions that // follow it, and none of those results can change the cancellation that // already happened. Other hook events retain their existing flow-control // semantics. const observationalFanOut = event === 'turn_interrupt' // Determine execution order: post_* hooks run backward (for cleanup semantics) const isPost = event.startsWith('post_') // For post_* hooks, we need to process in reverse order (last registered runs first) const indicesToProcess: number[] = [] if (isPost) { for (let i = handlers.length - 1; i >= 0; i--) { indicesToProcess.push(i) } } else { for (let i = 0; i < handlers.length; i++) { indicesToProcess.push(i) } } // Track input overlay so chained `modify` actions compose: each subsequent // hook sees the input produced by the previous hook's modify. let toolInputOverlay = context.toolInput for (const idx of indicesToProcess) { const hookEntry = handlers[idx] if (!hookEntry) continue const { pluginId, handler: handlerFn } = hookEntry const hookContext: PluginHookContext = { ...context, toolInput: toolInputOverlay, pluginId, event, } if (emitSessionEvent) { await emitSessionEvent({ type: 'plugin_hook_executing', sessionId: context.sessionId, ...(context.turnId !== undefined ? { turnId: context.turnId } : {}), pluginId, hookEvent: event, }) callerSignal?.throwIfAborted() } const start = performance.now() let result: PluginHookResult // The deadline timer is captured and cleared in `finally`. // Without that it stayed armed after the hook resolved, and an // armed timer keeps the Node event loop alive: hooks fire on // every tool call and every model call, so a turn of twenty tool // calls left twenty live timers and the process could not exit // until the last one expired. Nothing failed — it just hung, for // up to the timeout, every time. const deadline = new AbortController() const hookSignal = callerSignal ? AbortSignal.any([callerSignal, deadline.signal]) : deadline.signal let timer: ReturnType | undefined let onCallerAbort: (() => void) | undefined try { const races: Promise[] = [ Promise.resolve().then(() => handlerFn({ ...hookContext, signal: hookSignal })), new Promise((_, reject) => { timer = setTimeout(() => { // Told, not just abandoned: a hook holding a socket // open can close it. deadline.abort() reject(new Error('Hook timeout')) }, this.hookTimeoutMs) }), ] if (callerSignal) { races.push( new Promise((_, reject) => { onCallerAbort = () => reject(callerSignal.reason) callerSignal.addEventListener('abort', onCallerAbort, { once: true, }) if (callerSignal.aborted) onCallerAbort() }), ) } result = await Promise.race(races) callerSignal?.throwIfAborted() } catch (err) { // Cancellation belongs to the turn, not to the plugin. Turning it // into a hook error would let the query continue after its caller // withdrew authority and would report Stop as a plugin failure. if (callerSignal?.aborted) throw callerSignal.reason const message = toErrorMessage(err) result = { action: 'error', message } } finally { if (timer !== undefined) clearTimeout(timer) if (onCallerAbort) callerSignal?.removeEventListener('abort', onCallerAbort) } callerSignal?.throwIfAborted() const durationMs = Math.round(performance.now() - start) this.emit({ type: 'plugin_hook_executed', pluginId, hookEvent: event, durationMs, }) if (emitSessionEvent) { callerSignal?.throwIfAborted() await emitSessionEvent({ type: 'plugin_hook_completed', sessionId: context.sessionId, ...(context.turnId !== undefined ? { turnId: context.turnId } : {}), pluginId, hookEvent: event, result, }) callerSignal?.throwIfAborted() } results.push(result) if (!observationalFanOut && result.action === 'modify') { toolInputOverlay = result.input } // Handle flow control: check priority order: error > skip > retry > resume > modify > continue // Short-circuit on error or skip; return immediately on resume or retry if (!observationalFanOut && (result.action === 'error' || result.action === 'skip')) { break } if (!observationalFanOut && result.action === 'retry') { break } } return results } private emit(event: PluginLifecycleEvent): void { for (const listener of this.listeners) { try { listener(event) } catch (err) { this.log.error('Plugin event listener error', { 'exception.message': toErrorMessage(err), }) } } } }