diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index d0087d8c4f33..7d7c633c0668 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -310,6 +310,14 @@ coarse command/lifecycle event system and show up in `openclaw hooks list` as `plugin:`. Use those for side effects and compatibility with hook packs, not for ordered middleware or policy gates. +The legacy Plugin SDK `api.registerHook` registers into the internal event +system only (`command:new`, `gateway:startup`, `message:received`, ...). Typed +lifecycle event names such as `before_tool_call`, `message_received`, or +`session_start` are dispatched exclusively by the typed hook runner and are +**not** invoked through `registerHook`. Registering a typed name with +`registerHook` emits a registration warning pointing to the public `api.on(...)` +API as the replacement; it never silently no-ops. + For the complete plugin hook reference, see [Plugin hooks](/plugins/hooks). ## Configuration diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index c7af149c21cb..4fcb97a2d793 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -116,6 +116,89 @@ describe("loadOpenClawPlugins", () => { ); }); + it("warns when registerHook is used with a typed hook event name", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "typed-name-legacy-register", + filename: "typed-name-legacy-register.cjs", + body: `module.exports = { + id: "typed-name-legacy-register", + register(api) { + api.registerHook(["before_tool_call", "message_received"], () => {}, { + name: "typed-name-legacy-register", + }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["typed-name-legacy-register"], + }, + }, + onlyPluginIds: ["typed-name-legacy-register"], + }); + + expect(registry.legacyInternalHooks.map((entry) => entry.event)).toEqual([ + "before_tool_call", + "message_received", + ]); + expect( + registry.diagnostics + .filter( + (diagnostic) => + diagnostic.pluginId === "typed-name-legacy-register" && + diagnostic.level === "warn" && + diagnostic.message.includes("dispatched by the typed hook runner only"), + ) + .map((diagnostic) => diagnostic.message), + ).toEqual([ + expect.stringContaining('Use api.on("before_tool_call", ...)'), + expect.stringContaining('Use api.on("message_received", ...)'), + ]); + }); + + it("keeps legacy type:action events diagnostic-free in registerHook", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "legacy-type-action-register", + filename: "legacy-type-action-register.cjs", + body: `module.exports = { + id: "legacy-type-action-register", + register(api) { + api.registerHook("gateway:startup", () => {}, { name: "legacy-startup" }); + api.registerHook("command:new", () => {}, { name: "legacy-command" }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["legacy-type-action-register"], + }, + }, + onlyPluginIds: ["legacy-type-action-register"], + }); + + expect(registry.legacyInternalHooks.map((entry) => entry.event)).toEqual([ + "gateway:startup", + "command:new", + ]); + expect( + registry.diagnostics.some((diagnostic) => + diagnostic.message.includes("dispatched by the typed hook runner only"), + ), + ).toBe(false); + }); + it("runs consecutive plugin hook handlers with shared mutable context but isolated plugin config", async () => { useNoBundledPlugins(); const first = writePlugin({ diff --git a/src/plugins/registry-registrars-tools-hooks.ts b/src/plugins/registry-registrars-tools-hooks.ts index 8c69b3169890..8d31b0b8bf13 100644 --- a/src/plugins/registry-registrars-tools-hooks.ts +++ b/src/plugins/registry-registrars-tools-hooks.ts @@ -312,6 +312,22 @@ export function createToolHookRegistrars(state: PluginRegistryState) { pluginConfig: unknown, ) => { const normalizedEvents = normalizeStringEntries(Array.isArray(events) ? events : [events]); + // Typed lifecycle names (before_tool_call, message_received, ...) are dispatched only by + // the typed hook runner; registerHook uses the legacy internal-hook path so they never + // fire. Warn so authors move to `api.on(...)` instead of trusting a false "loaded". + for (const event of normalizedEvents) { + if (isPluginHookName(event)) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: + `hook event "${event}" is dispatched by the typed hook runner only; ` + + `api.registerHook registrations for it are not invoked. ` + + `Use api.on("${event}", ...) instead.`, + }); + } + } const entry = opts?.entry ?? null; const hookName = entry?.hook.name ?? opts?.name?.trim(); if (!hookName) {