Skip to content

[Bug]: Plugin api.on('command', ...) handlers never fire - triggerInternalHook and typedHooks are disconnected systems #25074

@jdvmi00

Description

@jdvmi00

Summary

Plugin hooks registered via api.on('command', ...) never fire because triggerInternalHook and the typed hook system (registry.typedHooks) are two completely separate dispatch systems with no bridge between them.

Steps to reproduce

  1. Write an OpenClaw plugin that registers a command hook:
register(api) {
  api.on('command', async (event) => {
    process.stderr.write(`[PROBE] command hook FIRED action=${event.action}\n`);
  });
}
  1. Install the plugin and restart the gateway.
  2. Hit /new in any session (webchat, Telegram, etc.).
  3. Check stderr - [PROBE] command hook FIRED never appears.

Expected behavior

When /new or /reset is issued, the gateway calls triggerInternalHook('command', ...) which should dispatch to any plugin handlers registered via api.on('command', ...).

Actual behavior

The handler never fires. api.on('command', ...) pushes to registry.typedHooks[] (System 2), but triggerInternalHook only reads from the module-level handlers Map in internal-hooks.ts (System 1). The two systems have no bridge.

Root cause (traced to source)

There are two independent hook dispatch systems:

System 1 - Internal hooks (subsystem-CYc2uK4P.js ~line 29):

const handlers = new Map(); // module-level

function registerInternalHook(eventKey, handler) {
  if (!handlers.has(eventKey)) handlers.set(eventKey, []);
  handlers.get(eventKey).push(handler);
}

async function triggerInternalHook(event) {
  const typeHandlers = handlers.get(event.type) ?? [];
  // only reads from 'handlers' Map - never touches registry.typedHooks
}

System 2 - Typed plugin hooks (subsystem-CYc2uK4P.js ~line 522):

const registerTypedHook = (record, hookName, handler, opts) => {
  registry.typedHooks.push({ pluginId, hookName, handler, ... });
  // stored in registry.typedHooks[] - never bridged to System 1
};
// api.on() calls registerTypedHook

The disconnect: When sessions.reset fires triggerInternalHook('command', ...), it dispatches from System 1. Plugin handlers registered via api.on('command', ...) live in System 2. They never meet.

The only bridge between the two systems is in the older registerHook() path (~line 371), which calls registerInternalHook after checking config.hooks.internal.enabled === true. api.on() (registerTypedHook) has no such bridge.

Additionally, 'command' is not in the PluginHookName union type and the hookRunner has no runCommand() method, so there is no typed hook dispatch path for command events at all.

Impact

Plugins cannot intercept session reset events (/new / /reset) from the gateway RPC path. This makes it impossible for plugins to perform pre-reset work (summarization, handoff storage, cleanup) when a session is reset via webchat or any other RPC-originated path.

The before_reset hook fires correctly for chat-originated /new (the auto-reply path), but NOT for RPC-originated resets - meaning there is no reliable hook available to plugins for session reset events.

Suggested fix

Two options:

Option A - Bridge triggerInternalHook to typed hooks: when dispatching, also call any registry.typedHooks entries whose hookName matches event.type.

Option B - Add 'command' to PluginHookName, add hookRunner.runCommand(), and call it from sessions.reset in addition to (or instead of) triggerInternalHook.

Option B is cleaner as it brings command events into the same typed system as all other plugin lifecycle hooks.

Environment

  • OpenClaw: 2026.2.22-2
  • Node: v24.13.0
  • macOS 25.2.0 (arm64)

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleMarked as stale due to inactivity

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions